| |

Dart 75 🎯 Middleware Pipeline Design in Shelf-based Backend Services

⏱️ Estimated read: 15 min | |

The middleware pipeline is the architectural spine of a Shelf application. Every request that enters the server passes through the pipeline before it reaches a route handler, and every response passes back through it on the way out. The pipeline is not a single function but a composition of functions, each wrapping the next, each able to inspect the request, modify it, short-circuit the chain, or transform the response. Understanding how this composition works—and the order in which the layers execute—is the difference between a server that is maintainable and one that is a tangle of cross-cutting concerns scattered across route handlers.

Shelf defines a Handler as any function that takes a Request and returns a Response, and middleware as a function that takes a handler and returns a new handler that wraps it . The Pipeline class chains these wrappers together. The first middleware added to the pipeline is the outermost layer: it sees the request first and the response last. The last handler added is the innermost: it sees the request last and the response first . This ordering is not an implementation detail. It determines whether a logging middleware logs the final status code or the initial one, whether a CORS middleware can short-circuit a preflight request before authentication runs, and whether an error handler can catch exceptions thrown by the layers below it.

Key point: The pipeline is a stack of wrappers. The first middleware added is the outermost; it runs first on the request and last on the response. The innermost handler is the route or the final request processor. Middleware that short-circuits—returns a response without calling the inner handler—stops the chain entirely, and every outer middleware still sees that response on the way back.


Why the pipeline order is the design

The logging problem. A logRequests() middleware placed at the top of the pipeline logs every request, every response status code, and the elapsed time. If it were placed after an authentication middleware that short-circuits unauthenticated requests with a 401, the log would never see those requests . The authentication middleware would return a response without calling the inner handler, and the logging middleware—if it were inside the auth layer—would never run. Placing logging first ensures that every request, including the ones that are rejected early, appears in the logs.

The error handling problem. An error handler middleware wraps the inner handler in a try/catch. If a route throws an exception, the error handler catches it, logs it, and returns a 500 response. For this to work, the error handler must be outside the layers that might throw—it must be closer to the top of the pipeline than the router and the route handlers . If the error handler were inside the router, a route that throws before the error handler runs would bypass it entirely.

The short-circuit problem. A middleware can return a response without calling the inner handler. This is how authentication works: if the request lacks a valid token, the auth middleware returns a 401 and the request never reaches the route. This is also how CORS preflight works: an OPTIONS request is answered by the CORS middleware and the chain stops . The key insight is that a short-circuit does not skip the outer middleware. The response travels back through every layer that was already entered, which is why logging placed above the auth middleware still logs the 401 .

The context propagation problem. Middleware that routes requests between handlers must update the request’s handlerPath and url so that inner handlers know where they are in the application . This is what shelf_router does when it matches a route: it creates a new request with the matched portion removed, so the route handler receives a request whose url is relative to the route. Without this, nested routers and middleware that inspect the path would see the full path instead of the remaining path.


a. Building the pipeline: logging, error handling, and the base composition

The pipeline is constructed once and passed to serve(). The order of the addMiddleware calls is the order of the layers, from outermost to innermost.

// Import the core Shelf libraries
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';

void main() async {
  // The router is the innermost handler
  // it dispatches to the route handlers based on the URL
  final router = Router();

  // A route that returns a successful response
  router.get('/hello', (Request request) {
    return Response.ok('hello-world');
  });

  // A route that throws, to demonstrate the error handler
  router.get('/error', (Request request) {
    throw Exception('Something went wrong');
  });

  // The pipeline composes the middleware around the router
  // The order is outermost to innermost:
  // 1. logRequests() logs the request and the response
  // 2. _errorHandler() catches any exception from the router
  // 3. router.call dispatches to the matching route
  final app = const Pipeline()
      // Logging is outermost so it sees every request and response
      .addMiddleware(logRequests())
      // Error handling is inside logging so the log sees the 500
      .addMiddleware(_errorHandler())
      // The router is the innermost handler
      .addHandler(router.call);

  // Start the server with the composed handler
  await shelf_io.serve(app, 'localhost', 8080);
}

// A middleware that catches errors from the inner handler
// It returns a Middleware, which is a function from Handler to Handler
Middleware _errorHandler() {
  // The outer function receives the inner handler
  return (Handler inner) {
    // The returned function is the new handler
    return (Request request) async {
      try {
        // Call the inner handler and return its response
        return await inner(request);
      } catch (error, stackTrace) {
        // Log the error and the stack trace to the server console
        print('Unhandled error: $error\n$stackTrace');
        // Return a generic 500 response to the client
        // The stack trace is not included in the response
        return Response.internalServerError(body: 'Internal server error');
      }
    };
  };
}

The _errorHandler is a Middleware, which Shelf defines as a function that takes a Handler and returns a Handler . The outer function receives the inner handler when the pipeline is constructed. The returned function is the actual handler that runs for each request. This structure is what allows the middleware to wrap the inner handler in a try/catch without knowing what the inner handler does.

The logRequests() middleware is the simplest production addition. It logs the request time, the elapsed duration, the method, the status code, and the requested URI. If the inner handler throws, it logs the error and the stack trace, then re-throws the error so that the error handler above it can catch it . The order matters: logging is above the error handler, so the log sees the 500 response that the error handler returns.


b. Short-circuiting: authentication and CORS

A middleware that returns a response without calling the inner handler stops the chain. This is the mechanism for authentication, authorization, and CORS preflight handling.

// A middleware that checks for a valid API key
// If the key is missing or invalid, it returns 401 without calling the inner handler
Middleware _apiKeyAuth(String expectedKey) {
  return (Handler inner) {
    return (Request request) async {
      // Read the API key from the request headers
      final apiKey = request.headers['x-api-key'];

      // If the key is missing or incorrect, short-circuit with 401
      if (apiKey != expectedKey) {
        // Return a response without calling inner
        // The request never reaches the router
        return Response.unauthorized('Missing or invalid API key');
      }

      // The key is valid, so continue down the pipeline
      return inner(request);
    };
  };
}

// A middleware that handles CORS preflight requests
// It answers OPTIONS requests and adds CORS headers to all responses
Middleware _cors() {
  return (Handler inner) {
    return (Request request) async {
      // If the request is a preflight OPTIONS request, answer it immediately
      if (request.method == 'OPTIONS') {
        // Short-circuit: the request never reaches the router
        return Response.ok('', headers: _corsHeaders);
      }

      // For non-preflight requests, call the inner handler
      final response = await inner(request);

      // Add the CORS headers to the response
      return response.change(headers: _corsHeaders);
    };
  };
}

// The CORS headers that are added to every response
const _corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
};

The _apiKeyAuth middleware demonstrates the core short-circuit pattern. The if branch returns a Response without calling inner(request). The else branch—the implicit fallthrough—calls inner(request) and returns whatever the inner handler returns. This is the pattern that the Stack Overflow question about middleware execution order was missing: the auth middleware in that question called AuthProvider.auth(request) but did not return the result, so the function fell through to the next case and the pipeline continued . The fix was adding the missing return.

The _cors middleware demonstrates the response transformation pattern. It calls the inner handler, waits for the response, and then returns a modified version of that response with the CORS headers added. The response.change(headers: ...) method creates a new response with the additional headers, preserving the body and status code. This is how a middleware can add headers without knowing anything about the inner handler’s logic.


c. Composition order and the production pipeline

The order of the middleware in the pipeline is the design decision. The correct order for a production API is: logging first, then CORS, then authentication, then rate limiting, then error handling, then the router. Each position has a reason.

// The production pipeline order
final app = const Pipeline()
    // 1. Logging: sees every request, including the ones that are rejected
    .addMiddleware(logRequests())
    // 2. CORS: handles preflight before authentication
    //    (a browser's preflight request has no auth headers)
    .addMiddleware(_cors())
    // 3. Authentication: rejects unauthenticated requests before they
    //    consume rate limit or reach the router
    .addMiddleware(_apiKeyAuth('secret-key'))
    // 4. Rate limiting: applied after auth so anonymous requests
    //    are rejected by auth first, not by the rate limiter
    .addMiddleware(_rateLimiter())
    // 5. Error handling: catches exceptions from the router and below
    .addMiddleware(_errorHandler())
    // 6. The router: the innermost handler
    .addHandler(router.call);

Logging first. Every request, including the ones rejected by auth or CORS, appears in the log. If logging were after auth, the 401 responses would be invisible, and the operator would not know that a client is failing to authenticate .

CORS before auth. A browser’s preflight OPTIONS request does not include the Authorization header. If auth ran before CORS, the preflight would be rejected with a 401, and the browser would never send the actual request . CORS must answer the preflight before auth has a chance to reject it.

Auth before rate limiting. An unauthenticated request should be rejected by auth, not counted against a rate limit. If rate limiting ran first, an attacker could exhaust the rate limit with unauthenticated requests and deny service to legitimate users. Auth first means only authenticated requests consume the rate limit budget.

Error handling inside logging. The error handler catches exceptions and returns a 500. If it were outside logging, the log would see the exception propagate up and crash the server instead of seeing the 500 response. The error handler must be inside logging so the response is logged .

The router last. The router is the innermost handler. It is the only layer that actually produces a response for a valid request. Every middleware above it either short-circuits or passes the request through.


Complete Example Session

The following session builds a complete pipeline with logging, CORS, authentication, rate limiting, error handling, and routing, and shows the execution order for a request that is rejected by authentication.

// ============================================
// PART 1: THE IMPORTS
// ============================================
import 'dart:async';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';

// ============================================
// PART 2: THE ROUTER
// ============================================
final router = Router()
  ..get('/hello', (Request request) => Response.ok('hello'))
  ..get('/error', (Request request) => throw Exception('boom'));

// ============================================
// PART 3: THE MIDDLEWARE
// ============================================
// Logging is built into Shelf, no custom code needed
// CORS middleware that answers preflight and adds headers
Middleware _cors() => (inner) => (request) async {
      if (request.method == 'OPTIONS') {
        return Response.ok('', headers: _corsHeaders);
      }
      final response = await inner(request);
      return response.change(headers: _corsHeaders);
    };

const _corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, OPTIONS',
  'Access-Control-Allow-Headers': 'Authorization',
};

// Auth middleware that short-circuits with 401
Middleware _auth() => (inner) => (request) {
      final token = request.headers['authorization'];
      if (token != 'Bearer secret') {
        return Response.unauthorized('Invalid token');
      }
      return inner(request);
    };

// Error handler that catches exceptions from the router
Middleware _errorHandler() => (inner) => (request) async {
      try {
        return await inner(request);
      } catch (e, st) {
        print('Error: $e');
        return Response.internalServerError(body: 'Internal error');
      }
    };

// ============================================
// PART 4: THE PIPELINE
// ============================================
final app = const Pipeline()
    // 1. Logging sees every request
    .addMiddleware(logRequests())
    // 2. CORS handles preflight before auth
    .addMiddleware(_cors())
    // 3. Auth rejects unauthenticated requests
    .addMiddleware(_auth())
    // 4. Error handling catches router exceptions
    .addMiddleware(_errorHandler())
    // 5. Router dispatches to routes
    .addHandler(router.call);

// ============================================
// PART 5: THE SERVER
// ============================================
void main() async {
  await shelf_io.serve(app, 'localhost', 8080);
}

// ============================================
// PART 6: EXECUTION TRACE
// ============================================
// Request: GET /hello with no Authorization header
//
// 1. logRequests: enters, records start time
// 2. _cors: not OPTIONS, calls inner
// 3. _auth: no token, returns 401 WITHOUT calling inner
// 4. _errorHandler: never entered (auth short-circuited)
// 5. router: never reached
// 6. _cors: receives the 401, adds CORS headers
// 7. logRequests: logs 401 and elapsed time
//
// The request never reaches the router, but it is logged.

The six parts cover the router, the middleware, the pipeline composition, the server startup, and the execution trace. The trace is the part that matters: the request is rejected by auth, the router is never reached, but the 401 response still passes back through CORS (which adds headers) and logging (which logs it). The short-circuit stops the inner chain but does not skip the outer layers.


Quick Reference

Middleware Execution Order

LayerRequest DirectionResponse Direction
OutermostFirstLast
InnermostLastFirst
Short-circuitStops inner chainOuter layers still run

Pipeline Composition

PositionMiddlewareReason
1LoggingSees every request and response
2CORSPreflight before auth
3AuthReject before rate limit
4Rate limitOnly authenticated requests count
5Error handlerCatches router exceptions
6RouterInnermost, produces response

Middleware Patterns

PatternCode
Pass-throughreturn inner(request)
Short-circuitreturn Response.unauthorized()
Response transformreturn (await inner(request)).change(headers: ...)
Error catchtry { return await inner(request); } catch (e) { ... }

Best Practices

✅ Do This:

// Logging outermost so every request is logged
.addMiddleware(logRequests())

// Return the inner handler's response, don't fall through
return inner(request);

// Short-circuit with a response when rejecting
return Response.unauthorized('Invalid token');

// Add CORS headers before returning the inner response
return response.change(headers: _corsHeaders);

❌ Don’t Do This:

// Call the inner handler without returning its result
inner(request); // the response is lost, chain falls through

// Put auth before CORS (preflight has no auth header)
.addMiddleware(_auth()).addMiddleware(_cors())

// Put error handler outside logging (the 500 is never logged)
.addMiddleware(_errorHandler()).addMiddleware(logRequests())

Common Pitfalls

PitfallWhy It HappensFix
Auth does not stop the chainForgot return on the inner callreturn inner(request)
Preflight rejected by authCORS after authCORS before auth
Error not loggedError handler outside loggingLogging outermost
500 not caughtError handler inside routerError handler before router
Response lostMiddleware calls inner without returningReturn the inner result

Real-World Examples

1. Pass-through middleware

Middleware passthrough() => (inner) => (request) => inner(request);

2. Short-circuit auth

if (token == null) return Response.unauthorized();

3. Response header addition

final response = await inner(request);
return response.change(headers: {'X-Custom': 'value'});

4. Error handler

try { return await inner(request); } catch (e) { return Response.internalServerError(); }

5. CORS preflight

if (request.method == 'OPTIONS') return Response.ok('', headers: corsHeaders);

6. Logging with custom logger

addMiddleware(logRequests(logger: (msg, isError) => myLogger.log(msg)));

7. Rate limit short-circuit

if (requests > limit) return Response(429);

8. Request context injection

final request = request.change(context: {'user': user});
return inner(request);

9. Multiple short-circuits

// CORS, then auth, then rate limit
// each can return without calling inner

10. Router as handler

.addHandler(router.call)

Visual

Pipeline Stack

┌──────────────────────────────────────────────┐
│  REQUEST                                     │
│         │                                    │
│         ▼                                    │
│  logRequests()     ← outermost               │
│         │                                    │
│         ▼                                    │
│  _cors()                                       │
│         │                                    │
│         ▼                                    │
│  _auth()           ← short-circuit here      │
│         │                                    │
│         ▼ (if authenticated)                 │
│  _errorHandler()                             │
│         │                                    │
│         ▼                                    │
│  Router            ← innermost               │
│         │                                    │
│         ▼                                    │
│  RESPONSE                                    │
└──────────────────────────────────────────────┘

Short-Circuit Flow

┌──────────────────────────────────────────────┐
│  _auth() returns 401 without calling inner   │
│         │                                    │
│         ▼                                    │
│  _cors() receives 401, adds CORS headers     │
│         │                                    │
│         ▼                                    │
│  logRequests() logs 401 and elapsed time     │
│         │                                    │
│         ▼                                    │
│  Client receives 401                         │
│                                              │
│  Router was never reached.                   │
└──────────────────────────────────────────────┘

Correct vs Incorrect Middleware

┌──────────────────────────────────────────────┐
│  CORRECT                                     │
│  return inner(request);                      │
│  → response is returned, chain continues     │
│                                              │
│  INCORRECT                                   │
│  inner(request);                             │
│  → response is lost, function falls through  │
│  → the next case runs, pipeline continues    │
└──────────────────────────────────────────────┘

Order Matters

┌──────────────────────────────────────────────┐
│  LOGGING BEFORE AUTH                         │
│  Request → log → auth → 401                  │
│  Log shows: GET /hello [401]                 │
│                                              │
│  AUTH BEFORE LOGGING                         │
│  Request → auth → 401 (logging never runs)   │
│  Log shows: nothing                          │
└──────────────────────────────────────────────┘

Summary

ItemValue
HandlerFunction from Request to Response
MiddlewareFunction from Handler to Handler
PipelineComposes middleware around a handler
Outer layerFirst request, last response
Inner layerLast request, first response
Short-circuitReturn without calling inner
Response transform(await inner(request)).change(...)
OrderLogging → CORS → Auth → Rate limit → Error → Router

Key takeaways:

  • The first middleware added is the outermost. It sees the request first and the response last. The last handler is the innermost and sees the request last .
  • Short-circuiting stops the inner chain but not the outer layers. A middleware that returns a response without calling the inner handler stops the request from reaching the router, but the response still travels back through every layer that was already entered .
  • The missing return is the most common bug. Calling inner(request) without returning its result causes the function to fall through, and the pipeline continues as if the middleware did nothing. The response is lost .
  • The order is the design. Logging first so every request is logged. CORS before auth so preflight requests are not rejected. Auth before rate limiting so unauthenticated requests do not consume the budget. Error handling inside logging so the 500 response is logged .
  • Response transformation uses change(). A middleware that wants to add headers to the inner handler’s response calls (await inner(request)).change(headers: ...) .
  • The router is the innermost handler. It is the only layer that produces a response for a valid request. Every middleware above it either short-circuits or passes the request through .

Remember: The middleware pipeline is a stack of wrappers, and the order of the stack is the architecture of the server. The outermost layer sees everything; the innermost layer sees only what the outer layers allowed through. Short-circuiting is how a layer says “stop, this request is not valid” without unwinding the stack—the response travels back through the outer layers, which can still add headers, log the outcome, or transform the response. The missing return is the bug that breaks the entire model: a middleware that calls the inner handler without returning its result is a middleware that does nothing. The pipeline is simple—a function that wraps a function—but the discipline of returning the inner result and ordering the layers correctly is what makes it work.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!