Zum Inhalt springen

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.

4 Min. Lesezeit
TypeScript-Code, der eine komponierbare Middleware-Pipeline mit typisierten Kontextobjekten zeigt

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.

tstypescript
// ❌ 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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

tstypescript
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

  1. Framework-Middleware-Signaturen verstecken Verträge — typisierte Kontextobjekte machen die Invarianten der Pipeline zur Compile-Zeit sichtbar
  2. Compose als Primitiv — eine kleine compose-Funktion lässt dich Pipelines unabhängig von jedem Framework bauen und macht sie portabel und testbar
  3. Mit typisierten Fehlern abbrechen, an der Grenze behandeln — einzelne Middlewares sollten werfen, nicht antworten; ein Adapter pro Einstiegspunkt übersetzt Fehler in HTTP
  4. Middleware als reine Funktionen testen — kein Testserver nötig; nur ein Kontextobjekt, ein gemocktes next und eine Assertion
  5. 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
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX