Shelf Framework Guide
Applies to: Shelf 1.x, Dart 3.x, REST APIs, Microservices, Backend Services Complements:
.claude/skills/dart-guide/SKILL.md
Core Principles
- •Middleware Composition: Everything flows through
Pipeline; compose handlers withaddMiddlewareandaddHandler - •Handler Simplicity: A
Handleris justFutureOr<Response> Function(Request)-- keep it functional - •Immutable Requests: Use
request.change()to pass data downstream viacontext - •Cascade Routing: Use
Cascadeto try multiple handlers in sequence until one succeeds - •Separation of Concerns: Handlers call services, services call repositories -- no business logic in middleware
Project Structure
code
myapp/ ├── bin/ │ └── server.dart # Entry point (thin: config, serve, shutdown) ├── lib/ │ ├── src/ │ │ ├── app.dart # Pipeline + Router assembly │ │ ├── config/ │ │ │ └── config.dart # Environment-based configuration │ │ ├── handlers/ │ │ │ ├── health_handler.dart │ │ │ └── users_handler.dart │ │ ├── middleware/ │ │ │ ├── auth_middleware.dart │ │ │ ├── cors_middleware.dart │ │ │ └── logging_middleware.dart │ │ ├── models/ │ │ │ └── user.dart │ │ ├── repositories/ │ │ │ └── user_repository.dart │ │ └── services/ │ │ └── user_service.dart │ └── myapp.dart # Library barrel export ├── test/ │ ├── handlers/ │ │ └── users_handler_test.dart │ └── middleware/ │ └── auth_middleware_test.dart ├── pubspec.yaml ├── analysis_options.yaml └── Dockerfile
Architectural rules:
- •
bin/server.dartis thin: load config, create handler, callshelf_io.serve, wire shutdown - •
lib/src/app.dartowns the Pipeline and Router composition - •Handlers are organized by resource; each handler class exposes a
Router get router - •Middleware functions return
Middleware(a typedef forHandler Function(Handler)) - •Models use
freezedfor immutability andjson_serializablefor serialization - •Services contain business logic; repositories handle data access
Dependencies (pubspec.yaml)
yaml
dependencies: shelf: ^1.4.0 shelf_router: ^1.1.0 shelf_static: ^1.1.0 # Static file serving shelf_web_socket: ^1.0.0 # WebSocket support # Data freezed_annotation: ^2.4.0 json_annotation: ^4.8.0 # Auth dart_jsonwebtoken: ^2.12.0 bcrypt: ^1.1.0 dev_dependencies: test: ^1.24.0 mocktail: ^1.0.0 build_runner: ^2.4.0 freezed: ^2.4.0 json_serializable: ^6.7.0
Application Entry Point
dart
// bin/server.dart
import 'dart:io';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:myapp/myapp.dart';
Future<void> main() async {
final config = Config.fromEnvironment();
final app = Application(config);
final handler = await app.createHandler();
final server = await shelf_io.serve(
handler,
InternetAddress.anyIPv4,
config.port,
);
print('Server running on http://${server.address.host}:${server.port}');
// Graceful shutdown
ProcessSignal.sigint.watch().listen((_) async {
print('Shutting down...');
await app.close();
await server.close();
exit(0);
});
}
Entry Point Rules
- •Load configuration from environment variables (never hardcode secrets)
- •Wire graceful shutdown via
ProcessSignal.sigint - •Call
app.close()beforeserver.close()to release database connections - •Keep
main()under 25 lines
Configuration
- •Use a
Configclass withfactory Config.fromEnvironment()reading fromPlatform.environment - •Provide dev defaults for non-secret values (port, database URL)
- •Throw
StateErrorin production if critical secrets are missing (JWT_SECRET, API keys) - •Use
constconstructor for Config to enable compile-time checks - •Never log secret values
Application Setup (Pipeline + Router)
dart
// lib/src/app.dart
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
class Application {
final Config config;
late final UserRepository _userRepository;
late final UserService _userService;
Application(this.config);
Future<Handler> createHandler() async {
_userRepository = UserRepository();
_userService = UserService(_userRepository, config);
final healthHandler = HealthHandler();
final usersHandler = UsersHandler(_userService);
final router = Router()
..mount('/health', healthHandler.router.call)
..mount('/api/v1/users', usersHandler.router.call);
final pipeline = const Pipeline()
.addMiddleware(loggingMiddleware())
.addMiddleware(corsMiddleware())
.addMiddleware(handleErrors())
.addHandler(router.call);
return pipeline;
}
Future<void> close() async {
await _userRepository.close();
}
}
Pipeline Rules
- •Middleware order: logging -> CORS -> error handling -> router
- •Logging outermost so it captures all requests including CORS preflight
- •Error handling wraps the router so thrown exceptions are caught
- •Always call
.callwhen mounting aRouteror passing toaddHandler - •Use
const Pipeline()for the initial empty pipeline
Handlers
Handlers are classes that expose a Router get router property. Each route method receives a Request and returns a Response.
dart
// lib/src/handlers/users_handler.dart
class UsersHandler {
final UserService _userService;
UsersHandler(this._userService);
Router get router {
final router = Router();
// Public routes
router.post('/register', _register);
router.post('/login', _login);
// Protected routes
router.get('/', _withAuth(_getAll));
router.get('/<id>', _withAuth(_getById));
router.put('/<id>', _withAuth(_update));
router.delete('/<id>', _withAuth(_delete));
return router;
}
Handler _withAuth(Handler handler) {
return const Pipeline()
.addMiddleware(authMiddleware())
.addHandler(handler);
}
Future<Response> _register(Request request) async {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final user = await _userService.register(
email: json['email'] as String,
password: json['password'] as String,
name: json['name'] as String,
);
return Response(
201,
body: jsonEncode(user.toJson()),
headers: {'Content-Type': 'application/json'},
);
}
}
Handler Rules
- •One handler class per resource (UsersHandler, ProductsHandler)
- •Keep handler methods under 20 lines; delegate business logic to services
- •Use
_withAuth(handler)helper to wrap individual routes with auth middleware - •Parse request body with
request.readAsString()thenjsonDecode() - •Always set
Content-Type: application/jsonon JSON responses - •Return appropriate status codes:
201for creation,204for deletion,200for reads/updates - •Use
shelf_routerpath parameters with angle brackets:/<id>
Middleware
A Middleware is a function that takes a Handler and returns a new Handler.
dart
// Middleware type signature typedef Middleware = Handler Function(Handler innerHandler);
Writing Middleware
dart
Middleware loggingMiddleware() {
return (Handler innerHandler) {
return (Request request) async {
final stopwatch = Stopwatch()..start();
print('[${DateTime.now()}] ${request.method} ${request.requestedUri}');
final response = await innerHandler(request);
stopwatch.stop();
print(
'[${DateTime.now()}] ${request.method} ${request.requestedUri} '
'${response.statusCode} ${stopwatch.elapsedMilliseconds}ms',
);
return response;
};
};
}
Error Handling Middleware
dart
Middleware handleErrors() {
return (Handler innerHandler) {
return (Request request) async {
try {
return await innerHandler(request);
} on NotFoundException catch (e) {
return _jsonError(404, e.message);
} on ValidationException catch (e) {
return _jsonError(422, e.message, errors: e.errors);
} on UnauthorizedException {
return _jsonError(403, 'Unauthorized');
} catch (e, stack) {
print('Error: $e\n$stack');
return _jsonError(500, 'Internal server error');
}
};
};
}
Response _jsonError(int status, String message, {Map<String, dynamic>? errors}) {
return Response(
status,
body: jsonEncode({
'error': message,
if (errors != null) 'errors': errors,
}),
headers: {'Content-Type': 'application/json'},
);
}
Middleware Rules
- •Return
Middleware(a function), notHandlerdirectly - •Always
await innerHandler(request)-- never skip calling the inner handler unless short-circuiting (auth failure, rate limit) - •Use
request.change(context: {...request.context, 'key': value})to pass data downstream - •Error-handling middleware must catch all exceptions and return proper HTTP responses
- •Never let unhandled exceptions propagate to
shelf_io.serve
Request and Response
Reading Request Data
| Source | How | Example |
|---|---|---|
| Body | await request.readAsString() then jsonDecode() | final json = jsonDecode(await request.readAsString()) |
| Query params | request.url.queryParameters['key'] | int.tryParse(request.url.queryParameters['page'] ?? '1') ?? 1 |
| Path params | Extra function arguments (shelf_router) | Future<Response> _getById(Request request, String id) |
| Headers | request.headers['Header-Name'] | request.headers['Authorization'] |
| Context | request.context['key'] | request.context['user'] as User |
Building Responses
| Status | Method |
|---|---|
| 200 OK | Response.ok(jsonEncode(data), headers: {'Content-Type': 'application/json'}) |
| 201 Created | Response(201, body: jsonEncode(data), headers: {'Content-Type': 'application/json'}) |
| 204 No Content | Response(204) |
| 404 Not Found | Response.notFound(jsonEncode({'error': 'Not found'}), headers: {'Content-Type': 'application/json'}) |
| Modify response | response.change(headers: {...response.headers, 'X-Custom': 'value'}) |
Request/Response Rules
- •Parse query parameters defensively with
int.tryParseand defaults - •Read the body only once (it is a stream); do not call
readAsString()twice - •Use
request.change(context:)for downstream data, never mutable globals - •Set
Content-Typeon every response that has a body
Routing with shelf_router
dart
final router = Router()
..get('/health', _health)
..get('/users', _listUsers)
..get('/users/<id>', _getUser)
..post('/users', _createUser)
..put('/users/<id>', _updateUser)
..delete('/users/<id>', _deleteUser);
// Mount sub-routers with path prefix
final root = Router()
..mount('/api/v1', apiRouter.call)
..mount('/ws', webSocketHandler);
Routing Rules
- •Use
..method('/path', handler)cascade syntax for readability - •Mount sub-routers with
..mount('/prefix', router.call) - •Path parameters use angle brackets:
/<id>,/<slug> - •Always version API routes:
/api/v1/... - •Group related routes in a single handler class
Cascade (Fallback Routing)
Cascade tries handlers in order until one returns a non-404 response.
dart
import 'package:shelf/shelf.dart';
import 'package:shelf_static/shelf_static.dart';
final cascade = Cascade()
.add(apiRouter)
.add(createStaticHandler('public', defaultDocument: 'index.html'));
final handler = const Pipeline()
.addMiddleware(loggingMiddleware())
.addHandler(cascade.handler);
Cascade Rules
- •Place specific handlers (API) before generic handlers (static files)
- •
Cascadetreats 404 and 405 as "not handled" by default - •Use
statusCodesparameter to customize which codes trigger fallthrough - •Useful for SPAs: API routes first, then static file handler as fallback
Custom Exception Types
dart
class UnauthorizedException implements Exception {
final String message;
UnauthorizedException([this.message = 'Unauthorized']);
}
class NotFoundException implements Exception {
final String message;
NotFoundException(this.message);
}
class ValidationException implements Exception {
final String message;
final Map<String, List<String>> errors;
ValidationException(this.message, [this.errors = const {}]);
}
Exception Rules
- •Define domain exceptions that implement
Exception(notError) - •Map exceptions to HTTP status codes in error-handling middleware only
- •Never throw generic
Exception('message')-- use typed exceptions - •Include structured error details (field-level validation errors)
Commands
bash
# Development dart run bin/server.dart # Start server dart run --enable-vm-service bin/server.dart # With debugger # Code generation (freezed, json_serializable) dart run build_runner build --delete-conflicting-outputs dart run build_runner watch # Watch mode for codegen # Testing dart test # Run all tests dart test test/handlers/ # Run specific directory dart test --coverage # With coverage # Quality dart format . # Format all files dart analyze # Static analysis dart fix --apply # Auto-fix lint issues # Build dart compile exe bin/server.dart -o server # AOT compile to native binary
Best Practices Summary
- •Pipeline: Compose middleware with
Pipeline; order matters (outermost runs first) - •Handlers: Keep thin; delegate to services; one class per resource
- •Error Handling: Centralize in middleware; catch all exceptions; return structured JSON errors
- •Context: Pass data between middleware and handlers via
request.change(context:) - •Testing: Use
shelfdirectly in tests (no HTTP server needed); mock services withmocktail - •Security: Validate all inputs; use parameterized database queries; never log secrets
- •Performance: Compile to native executable for production; use streaming for large responses
Advanced Topics
For detailed middleware examples, WebSocket support, static files, authentication flows, rate limiting, and testing patterns, see:
- •references/patterns.md -- Full middleware patterns, WebSocket handler, static file serving, JWT auth, rate limiting, repository pattern, integration testing, Dockerfile