Dart 75 🎯 Middleware Pipeline Design in Shelf-based Backend Services
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
| Layer | Request Direction | Response Direction |
|---|---|---|
| Outermost | First | Last |
| Innermost | Last | First |
| Short-circuit | Stops inner chain | Outer layers still run |
Pipeline Composition
| Position | Middleware | Reason |
|---|---|---|
| 1 | Logging | Sees every request and response |
| 2 | CORS | Preflight before auth |
| 3 | Auth | Reject before rate limit |
| 4 | Rate limit | Only authenticated requests count |
| 5 | Error handler | Catches router exceptions |
| 6 | Router | Innermost, produces response |
Middleware Patterns
| Pattern | Code |
|---|---|
| Pass-through | return inner(request) |
| Short-circuit | return Response.unauthorized() |
| Response transform | return (await inner(request)).change(headers: ...) |
| Error catch | try { 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Auth does not stop the chain | Forgot return on the inner call | return inner(request) |
| Preflight rejected by auth | CORS after auth | CORS before auth |
| Error not logged | Error handler outside logging | Logging outermost |
| 500 not caught | Error handler inside router | Error handler before router |
| Response lost | Middleware calls inner without returning | Return 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
| Item | Value |
|---|---|
| Handler | Function from Request to Response |
| Middleware | Function from Handler to Handler |
| Pipeline | Composes middleware around a handler |
| Outer layer | First request, last response |
| Inner layer | Last request, first response |
| Short-circuit | Return without calling inner |
| Response transform | (await inner(request)).change(...) |
| Order | Logging → 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
returnis the most common bug. Callinginner(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.
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!