shelf
SKILL.md
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
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 shutdownlib/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)
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
// 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)
// 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.
// 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.
// Middleware type signature
typedef Middleware = Handler Function(Handler innerHandler);
Writing Middleware
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
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
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.
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
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
# 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
External References
Weekly Installs
5
Repository
ar4mirez/samuelGitHub Stars
3
First Seen
13 days ago
Security Audits
Installed on
opencode5
gemini-cli5
codebuddy5
github-copilot5
codex5
kimi-cli5