Komponierbare Middleware-Pipelines in TypeScript
Hör auf, Middleware an Frameworks anzuflanschen: baue typsichere, komponierbare Pipeline-Primitive, die isoliert testbar und auf einen Blick klar sind.

Middleware gehört zu den Mustern, die jeder Backend-Entwickler ständig nutzt und fast niemand bewusst entwirft. Man erbt eine Express-App mit vierzig app.use()-Aufrufen oder ein Next.js-Projekt, in dem jede Route Auth und Logging anders reimplementiert, und akzeptiert das als den natürlichen Zustand der Dinge. Das muss nicht so sein.
Middleware als komponierbare, typisierte Primitive zu bauen — statt als aneinandergeflanschte Framework-Callbacks — macht Pipelines isoliert testbar, ohne Angst neu anordnbar und als Datenfluss lesbar statt als Seiteneffekte.
Das Problem mit framework-nativem Middleware
Express-Middleware hat eine Signatur, die überallhin kopiert wurde: (req, res, next) => void. Sie ist flexibel, aber genau diese Flexibilität ist das Problem. Nichts im Typsystem sagt dir, was eine Middleware auf req erwartet, was sie hinzufügt oder ob sie next überhaupt aufruft.
// ❌ Implicit contract — caller has no idea what this requires or produces
app.use(requireAuth);
app.use(loadTenant);
app.use(enforceRateLimits);
router.get("/invoices", async (req, res) => {
// Is req.user here? req.tenant? Who added them?
const invoices = await getInvoices(req.user.id, req.tenant.id);
res.json(invoices);
});Die Middleware lief — wahrscheinlich — aber es gibt keine Garantie zur Compile-Zeit. Wenn du diese drei Zeilen umstellst oder eine auslässt, bekommst du einen Laufzeitfehler und einen verwirrten Bereitschaftsingenieur um 2 Uhr morgens.
Eine typisierte Kontext-Pipeline definieren
Die Lösung ist, den Kontexttyp in jedem Schritt explizit zu machen. Eine Middleware-Funktion transformiert ein Kontextobjekt von einer Form in eine andere oder bricht mit einem Fehler ab.
type Next<TIn, TOut> = (ctx: TIn) => Promise<TOut>;
type Middleware<TIn, TOut, TNext = TOut> = (
ctx: TIn,
next: Next<TIn, TNext>,
) => Promise<TOut>;
// A pipeline stage that enriches the context
type Enrich<TIn, TExtra> = Middleware<TIn, TIn & TExtra>;Diese Signatur bedeutet, dass der Compiler erzwingt, dass jede Stufe genau das erhält, was sie braucht. Wenn enforceRateLimits eine user-Eigenschaft im Kontext braucht, typisiert es einfach seine Eingabe entsprechend — und der Build schlägt fehl, wenn du es vor requireAuth platzierst.
Die Compose-Funktion bauen
Eine Liste von Middleware zu einem einzigen Handler zu komponieren ist einfach, sobald die Typen stimmen.
function compose<TCtx>(
...middlewares: Array<(ctx: TCtx, next: () => Promise<TCtx>) => Promise<TCtx>>
): (ctx: TCtx) => Promise<TCtx> {
return function dispatch(ctx: TCtx): Promise<TCtx> {
let index = -1;
function step(i: number, currentCtx: TCtx): Promise<TCtx> {
if (i <= index) {
return Promise.reject(new Error("next() called multiple times"));
}
index = i;
const middleware = middlewares[i];
if (!middleware) return Promise.resolve(currentCtx);
return middleware(currentCtx, (nextCtx) => step(i + 1, nextCtx ?? currentCtx));
}
return step(0, ctx);
};
}Das ist dasselbe Zwiebelmodell, das Koa intern verwendet, aber extrahiert und unabhängig von jedem Framework typisiert. Du kannst es in einer Next.js-Route, einem Lambda-Handler, einem Hintergrund-Job-Prozessor oder einem reinen Unit-Test verwenden.
Konkrete Middleware-Implementierungen
So sieht echte Middleware in diesem Modell aus — jede Stufe deklariert, was sie braucht und was sie hinzufügt.
type BaseCtx = { requestId: string; startedAt: number };
type AuthCtx = BaseCtx & { user: { id: string; roles: string[] } };
type TenantCtx = AuthCtx & { tenant: { id: string; plan: "free" | "pro" } };
// Auth middleware: BaseCtx → AuthCtx
const withAuth: Enrich<BaseCtx, { user: AuthCtx["user"] }> = async (ctx, next) => {
const token = getTokenFromRequest(ctx.requestId);
const user = await verifyToken(token);
if (!user) {
throw new HttpError(401, "Unauthorized");
}
return next({ ...ctx, user });
};
// Tenant middleware: AuthCtx → TenantCtx
const withTenant: Enrich<AuthCtx, { tenant: TenantCtx["tenant"] }> = async (ctx, next) => {
const tenant = await getTenantForUser(ctx.user.id);
return next({ ...ctx, tenant });
};
// Rate limit middleware: TenantCtx → TenantCtx (no enrichment, may short-circuit)
const withRateLimit: Middleware<TenantCtx, TenantCtx> = async (ctx, next) => {
const allowed = await checkRateLimit(ctx.tenant.id, ctx.tenant.plan);
if (!allowed) {
throw new HttpError(429, "Rate limit exceeded");
}
return next(ctx);
};Da jede Middleware eine einfache Async-Funktion ist, kannst du sie einzeln testen, ohne einen Server zu starten, eine Datenbank zu befüllen oder ein komplettes Request-Objekt zu mocken.
Fehlerbehandlung ohne Details preiszugeben
Die Pipeline bei einem Fehler abzubrechen ist das richtige Standardverhalten, aber du brauchst genau eine Stelle, die Middleware-Fehler in HTTP-Antworten übersetzt. Das ist deine äußere Fehlergrenze — und sie gehört in den Framework-Adapter, nicht in die einzelnen Middlewares.
type HttpError = { status: number; message: string };
function isHttpError(e: unknown): e is HttpError {
return typeof e === "object" && e !== null && "status" in e && "message" in e;
}
// Framework adapter: wraps your typed pipeline in a Next.js route handler
function createRouteHandler<TCtx extends BaseCtx>(
pipeline: (ctx: TCtx) => Promise<TCtx>,
buildCtx: (req: Request) => TCtx,
respond: (ctx: TCtx) => Response,
): (req: Request) => Promise<Response> {
return async (req) => {
try {
const ctx = buildCtx(req);
const result = await pipeline(ctx);
return respond(result);
} catch (e) {
if (isHttpError(e)) {
return Response.json({ error: e.message }, { status: e.status });
}
console.error("[unhandled]", e);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
};
}Geschäftslogikfehler, die 404 oder 422 sein sollten, sind HttpError-Instanzen. Infrastrukturfehler steigen als unbehandelte Ausnahmen auf und werden an der Grenze abgefangen. Die Unterscheidung ist bedeutsam und bewusst gewählt.
Middleware isoliert testen
Der eigentliche Gewinn dieses Designs ist unit-testbare Middleware. Kein supertest, kein Testserver, nur einfache Funktionsaufrufe.
describe("withRateLimit", () => {
it("calls next when within limit", async () => {
const ctx: TenantCtx = {
requestId: "test-1",
startedAt: Date.now(),
user: { id: "u1", roles: ["member"] },
tenant: { id: "t1", plan: "pro" },
};
vi.mocked(checkRateLimit).mockResolvedValue(true);
const next = vi.fn().mockImplementation((c) => Promise.resolve(c));
await withRateLimit(ctx, next);
expect(next).toHaveBeenCalledWith(ctx);
});
it("throws HttpError(429) when limit exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue(false);
const ctx: TenantCtx = {
requestId: "test-2",
startedAt: Date.now(),
user: { id: "u1", roles: ["member"] },
tenant: { id: "t1", plan: "free" },
};
await expect(withRateLimit(ctx, vi.fn())).rejects.toMatchObject({
status: 429,
});
});
});Kein Mocking von req oder res. Keine done-Callbacks. Nur eine Funktion, eine Eingabe und eine Assertion.
Übergib Abhängigkeiten lieber explizit (als Konstruktorargumente oder über eine Factory), statt sie direkt in der Middleware zu importieren. Das hält das Test-Setup einfach und macht den Abhängigkeitsgraphen sichtbar.
Die finale Pipeline zusammensetzen
Alles zusammengesetzt liest sich ein Route-Handler wie eine Deklaration seiner Anforderungen.
const invoicesPipeline = compose(
withAuth,
withTenant,
withRateLimit,
);
export const GET = createRouteHandler(
invoicesPipeline,
(req) => ({
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
startedAt: Date.now(),
}),
async (ctx) => {
const invoices = await getInvoices(ctx.user.id, ctx.tenant.id);
return Response.json(invoices);
},
);Die Reihenfolge ist explizit und bedeutsam. Die Typen erzwingen sie. Eine neue Stufe hinzuzufügen — sagen wir withAuditLog — bedeutet zu deklarieren, was sie braucht, sie in die Kette einzufügen und den Compiler jede Lücke im erwarteten Kontext finden zu lassen.
Die wichtigsten Erkenntnisse
- Framework-Middleware-Signaturen verstecken Verträge — typisierte Kontextobjekte machen die Invarianten der Pipeline zur Compile-Zeit sichtbar
- Compose als Primitiv — eine kleine
compose-Funktion lässt dich Pipelines unabhängig von jedem Framework bauen und macht sie portabel und testbar - Mit typisierten Fehlern abbrechen, an der Grenze behandeln — einzelne Middlewares sollten werfen, nicht antworten; ein Adapter pro Einstiegspunkt übersetzt Fehler in HTTP
- Middleware als reine Funktionen testen — kein Testserver nötig; nur ein Kontextobjekt, ein gemocktes
nextund eine Assertion - Umstellen sollte ein Compiler-Fehler sein, keine Laufzeit-Überraschung — wenn Stufe B benötigt, was Stufe A produziert, erzwingen die TypeScript-Typen diese Reihenfolge automatisch


