Spry Middleware Patterns: A Complete Guide with Real Code Examples
A comprehensive guide to Spry middleware: build authentication, logging, and error‑handling middleware from scratch for production‑grade Dart servers.
Spry Middleware Patterns: A Complete Guide with Real Code Examples
Middleware is the backbone of any modern server framework. It allows you to intercept, transform, and enhance HTTP requests and responses in a reusable, composable way. Spry, the next‑generation Dart server framework, embraces middleware as a first‑class concept with a clean, predictable lifecycle and powerful scoping rules.
In this tutorial, we'll explore Spry's middleware system in depth. You'll learn:
- How middleware works in Spry (the onion‑style lifecycle)
- How to create your own middleware functions
- Practical use cases: logging, authentication, CORS, rate limiting, error handling
- How to integrate middleware with routes and global configurations
- Advanced patterns: conditional middleware, middleware chains, and scoped error boundaries
Every example in this guide is taken from a real, runnable Spry project. You can clone the spry repository and try them out immediately.
Prerequisites: Basic familiarity with Dart and HTTP concepts. You'll need Dart SDK ≥3.10 and the Spry package.
1. Middleware in Spry: How It Works
1.1 The Middleware Lifecycle
Spry middleware follows the classic onion model. Each middleware function receives an Event object (containing the request, parameters, locals, etc.) and a Next callback. The middleware can:
- Perform actions before passing control to the next layer (e.g., authenticate, log, rate‑limit)
- Call
next()to invoke the rest of the pipeline (which may include other middleware and the final route handler) - Perform actions after receiving the response from downstream layers (e.g., add headers, log timing)
- Return a
Responsedirectly, short‑circuiting the rest of the pipeline (e.g., for authentication failures)
Here's the simplest possible middleware – a pass‑through that does nothing:
import 'package:spry/spry.dart';
Future<Response> middleware(Event event, Next next) async {
// Before the route handler
return await next();
// After the route handler (if we had code here)
}
1.2 Middleware Registration
Spry supports two kinds of middleware placement:
| Placement | Path | Scope |
| Global | middleware/ directory | Applies to all routes |
| Scoped | _middleware.dart file | Applies to routes in the same directory and its subdirectories |
Global middleware files are executed in filename order (lexicographically). Scoped middleware follows the directory hierarchy: a _middleware.dart file in a subdirectory runs after any middleware defined in parent directories.
When a request arrives, Spry collects all matching middleware (global + scoped), reverses the list, and builds a chain where the first middleware in the list is the outermost layer. This ensures that global middleware runs before scoped middleware, and that middleware defined closer to the route runs closer to the route handler.
Technical note: Spry uses a
Router<Middleware>internally, which matches middleware based on path patterns and HTTP methods. The/**pattern matches all routes, which is what the global middleware directory uses.
2. Creating Middleware Functions
A middleware function in Spry has the following signature:
typedef Middleware = FutureOr<Response> Function(Event event, Next next);
Eventprovides access to the request, URL, parameters, locals storage, and the server context.Nextis a zero‑argument function that returns aFuture<Response>. Callingnext()proceeds to the next middleware (or the route handler).
Let's look at a concrete example – a logging middleware that records request duration:
import 'package:spry/spry.dart';
Future<Response> middleware(Event event, Next next) async {
final startedAt = DateTime.now();
final response = await next();
final duration = DateTime.now().difference(startedAt).inMilliseconds;
print(
'${event.method} ${event.url.path} -> ${response.status} (${duration}ms)',
);
return response;
}
Save this file as middleware/01_logger.dart. It will automatically be picked up and applied to every request.
2.1 Accessing and Modifying the Request
The Event object contains the incoming Request. You can read headers, query parameters, and the request body. For example, an authentication middleware might inspect the Authorization header:
final authHeader = event.request.headers.get('Authorization');
You can also attach data to event.locals – a thread‑safe storage that travels with the request through the middleware chain. This is ideal for passing user IDs, request IDs, or other context to downstream handlers.
2.2 Short‑Circuiting the Pipeline
If a middleware decides that the request should not proceed (e.g., missing credentials), it can return a Response directly without calling next(). This immediately ends the middleware chain and sends the response back to the client.
if (!isAuthenticated) {
return Response.json(
{'error': 'Unauthorized'},
ResponseInit(status: 401),
);
}
3. Practical Use Cases
Now let's implement five real‑world middleware examples. All of them are available in the companion project.
3.1 Logging Middleware
We already saw a basic logger. Here's an enhanced version that includes a unique request ID and logs in a structured format:
import 'package:spry/spry.dart';
Future<Response> middleware(Event event, Next next) async {
final requestId = DateTime.now().microsecondsSinceEpoch.toString();
event.locals.set(#requestId, requestId);
final startedAt = DateTime.now();
final response = await next();
final duration = DateTime.now().difference(startedAt).inMilliseconds;
print(
'[${event.method}] ${event.url.path} -> ${response.status} '
'(id:$requestId, ${duration}ms)',
);
// Add request ID to response headers for client‑side correlation
response.headers.set('X-Request-ID', requestId);
return response;
}
Place this in middleware/01_logger.dart. It will run first (because 01_ sorts before other files).
3.2 Authentication Middleware
A typical API‑key authentication middleware that protects specific routes:
import 'package:spry/spry.dart';
/// Simple authentication middleware that checks for an API key header.
Future<Response> middleware(Event event, Next next) async {
const apiKey = 'secret123'; // In production, use environment variables
final providedKey = event.request.headers.get('x-api-key');
if (providedKey != apiKey) {
return Response.json(
{'error': 'Unauthorized', 'message': 'Invalid or missing API key'},
ResponseInit(status: 401),
);
}
// Attach user info to locals for downstream handlers
event.locals.set(#userId, 'user_${DateTime.now().millisecondsSinceEpoch}');
return next();
}
Save as middleware/02_auth.dart. Now any request lacking the correct X‑API‑Key header will receive a 401 response.
3.3 CORS Middleware
Cross‑origin resource sharing (CORS) is essential for web APIs. This middleware adds the appropriate headers and handles preflight (OPTIONS) requests:
import 'package:spry/spry.dart';
/// CORS middleware that adds appropriate headers for cross‑origin requests.
Future<Response> middleware(Event event, Next next) async {
// Handle preflight requests
if (event.request.method == 'OPTIONS') {
return Response(
null,
ResponseInit(
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
'Access-Control-Max-Age': '86400',
},
),
);
}
final response = await next();
// Add CORS headers to the actual response
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Expose-Headers', 'Content-Length, X-Request-ID');
return response;
}
Save as middleware/03_cors.dart. It will handle OPTIONS requests directly and add CORS headers to all other responses.
3.4 Rate‑Limiting Middleware
Protect your API from abuse with a simple per‑IP rate limiter (in‑memory for demonstration; use Redis in production):
import 'package:spry/spry.dart';
import 'dart:collection';
/// In‑memory store for rate limiting (for demonstration only).
/// In production, use Redis or a distributed cache.
final _requestStore = <String, Queue<DateTime>>{};
const _maxRequests = 10;
const _windowSeconds = 60;
/// Rate limiting middleware that restricts each IP to 10 requests per minute.
Future<Response> middleware(Event event, Next next) async {
final ip = event.context.remoteAddress?.address ?? 'unknown';
final now = DateTime.now();
final windowStart = now.subtract(const Duration(seconds: _windowSeconds));
// Clean up old entries for this IP
final queue = _requestStore.putIfAbsent(ip, () => Queue<DateTime>());
while (queue.isNotEmpty && queue.first.isBefore(windowStart)) {
queue.removeFirst();
}
// Check if limit exceeded
if (queue.length >= _maxRequests) {
return Response.json(
{
'error': 'Rate limit exceeded',
'message': 'Too many requests, please try again later.',
'retryAfter': _windowSeconds,
},
ResponseInit(status: 429),
);
}
// Record this request
queue.add(now);
return next();
}
Save as middleware/04_rate_limit.dart. Clients exceeding 10 requests per minute will receive a 429 status.
3.5 Global Error‑Handling Middleware
While Spry provides scoped error boundaries via _error.dart files, you can also catch unhandled exceptions in a global middleware:
import 'package:spry/spry.dart';
/// Global error handling middleware that catches unhandled exceptions
/// and converts them to consistent JSON error responses.
Future<Response> middleware(Event event, Next next) async {
try {
return await next();
} catch (error, stackTrace) {
// Log the error (in production, use a proper logging service)
print('Unhandled error: $error');
print(stackTrace);
// Convert to appropriate HTTP error
if (error is NotFoundError) {
return Response.json(
{'error': 'not_found', 'path': event.url.path},
ResponseInit(status: 404),
);
}
// Default 500 error
return Response.json(
{
'error': 'internal_server_error',
'message': 'Something went wrong.',
// In development, you might include the error details
if (const bool.fromEnvironment('DEBUG')) 'detail': '$error',
},
ResponseInit(status: 500),
);
}
}
Save as middleware/05_error_handler.dart. This ensures your API never leaks raw stack traces to clients.
4. Integration with Routes and Global Middleware
4.1 Route‑Specific Middleware
Sometimes you want middleware to apply only to certain routes. Spry's scoped _middleware.dart files make this trivial.
Create a directory routes/api/ and inside it place a _middleware.dart file:
import 'package:spry/spry.dart';
/// Scoped middleware that adds API version header to all routes under /api.
Future<Response> middleware(Event event, Next next) async {
final response = await next();
response.headers.set('X-API-Version', 'v1');
return response;
}
Now any route under /api will automatically receive the X‑API‑Version: v1 header. For example, routes/api/status.get.dart:
import 'package:spry/spry.dart';
/// API status endpoint.
Response handler(Event event) {
return Response.json({
'status': 'operational',
'service': 'Spry API',
'timestamp': DateTime.now().toIso8601String(),
});
}
Visit GET /api/status and you'll see the version header in the response.
4.2 Conditional Middleware
You can skip or modify middleware behavior based on request properties. For instance, you might want authentication to apply only to certain paths:
import 'package:spry/spry.dart';
Future<Response> middleware(Event event, Next next) async {
// Skip authentication for public routes
if (event.url.path.startsWith('/public')) {
return next();
}
// Require API key for everything else
final apiKey = event.request.headers.get('x-api-key');
if (apiKey != 'secret123') {
return Response.json(
{'error': 'Unauthorized'},
ResponseInit(status: 401),
);
}
return next();
}
Or you could create a middleware that only runs in development:
import 'package:spry/spry.dart';
Future<Response> middleware(Event event, Next next) async {
if (const bool.fromEnvironment('DEBUG')) {
print('Debug middleware triggered for ${event.url.path}');
}
return next();
}
4.3 Middleware Chains
Because Spry automatically chains all matching middleware, you can compose complex behaviors from simple, single‑purpose functions. The order is determined by:
- Global middleware (sorted by filename)
- Scoped middleware (from outermost to innermost directory)
For example, with the following structure:
middleware/
01_logger.dart
02_auth.dart
03_cors.dart
routes/
api/
_middleware.dart
status.get.dart
A request to /api/status will execute middleware in this order:
01_logger(global)02_auth(global)03_cors(global)api/_middleware(scoped)status.get.dart(route handler)
The response then travels back outward through the same layers (post‑processing).
5. Advanced Patterns
5.1 Dynamic Middleware Registration
If you need programmatic control over middleware (e.g., conditionally adding middleware based on configuration), you can bypass the file‑based system and build the Spry instance manually.
import 'package:spry/spry.dart';
void main() {
final app = Spry(
routes: { ... },
middleware: [
MiddlewareRoute(path: '/**', handler: globalLogger),
if (shouldEnableAuth) MiddlewareRoute(path: '/admin/**', handler: authMiddleware),
],
errors: [ ... ],
);
}
This approach is useful when integrating Spry into an existing Dart application or when you need fine‑grained control over middleware ordering.
5.2 Middleware That Modifies the Request
Although the Event object is immutable, you can create a new Event with modified properties and pass it downstream. This is useful for adding default parameters or transforming the request URL.
import 'package:spry/spry.dart';
Future<Response> middleware(Event event, Next next) async {
// Add a default query parameter
final url = event.url.replace(queryParameters: {
...event.url.queryParameters,
'locale': 'en',
});
final newEvent = Event(
app: event.app,
request: event.request,
context: event.context,
params: event.params,
locals: event.locals,
url: url,
);
return next(newEvent);
}
Note: The
Eventconstructor is low‑level; most use cases are covered byevent.localsandevent.params.
5.3 Testing Middleware
Spry middleware are plain Dart functions, making them easy to unit test. Use the spry_test package (or your favorite testing framework) to mock Event and Next and verify behavior.
import 'package:spry/spry.dart';
import 'package:test/test.dart';
void main() {
test('auth middleware rejects missing API key', () async {
final event = Event(...); // Mock event with empty headers
final middleware = authMiddleware;
final response = await middleware(event, () => throw 'Should not be called');
expect(response.status, 401);
expect(await response.json(), contains('error'));
});
}
6. Conclusion
Spry's middleware system gives you the flexibility to implement cross‑cutting concerns in a clean, composable way. Whether you need logging, authentication, CORS, rate limiting, or custom error handling, you can build it with a few lines of Dart and let Spry handle the execution order and scoping.
Key takeaways:
- Global middleware lives in the
middleware/directory and runs for all routes. - Scoped middleware uses
_middleware.dartfiles and follows directory hierarchies. - The onion lifecycle ensures predictable ordering and post‑processing.
- Middleware can short‑circuit the pipeline, modify responses, attach context via
locals, and catch errors. - All middleware are plain Dart functions – easy to write, test, and reuse.
Next Steps
- Explore the official Spry documentation for more advanced topics like WebSockets, static file serving, and multi‑runtime deployment.
- Clone the spry repository and run the middleware examples.
- Join the Spry community discussions to share your middleware creations.
Try It Yourself
To experiment with the middleware examples in this guide, create a new Spry project (or use the existing tutorial project):
mkdir my_spry_app
cd my_spry_app
dart pub add spry
Copy the middleware files into middleware/ and the route examples into routes/, then start the server:
dart run spry serve
Visit http://localhost:4000/admin (without an API key) to see the authentication middleware in action, or http://localhost:4000/public to bypass it.
Happy middleware crafting! 🛡️