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

  1. Middleware Composition: Everything flows through Pipeline; compose handlers with addMiddleware and addHandler
  2. Handler Simplicity: A Handler is just FutureOr<Response> Function(Request) -- keep it functional
  3. Immutable Requests: Use request.change() to pass data downstream via context
  4. Cascade Routing: Use Cascade to try multiple handlers in sequence until one succeeds
  5. 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.dart is thin: load config, create handler, call shelf_io.serve, wire shutdown
  • lib/src/app.dart owns the Pipeline and Router composition
  • Handlers are organized by resource; each handler class exposes a Router get router
  • Middleware functions return Middleware (a typedef for Handler Function(Handler))
  • Models use freezed for immutability and json_serializable for 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() before server.close() to release database connections
  • Keep main() under 25 lines

Configuration

  • Use a Config class with factory Config.fromEnvironment() reading from Platform.environment
  • Provide dev defaults for non-secret values (port, database URL)
  • Throw StateError in production if critical secrets are missing (JWT_SECRET, API keys)
  • Use const constructor 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 .call when mounting a Router or passing to addHandler
  • 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() then jsonDecode()
  • Always set Content-Type: application/json on JSON responses
  • Return appropriate status codes: 201 for creation, 204 for deletion, 200 for reads/updates
  • Use shelf_router path 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), not Handler directly
  • 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.tryParse and 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-Type on 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)
  • Cascade treats 404 and 405 as "not handled" by default
  • Use statusCodes parameter 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 (not Error)
  • 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 shelf directly in tests (no HTTP server needed); mock services with mocktail
  • 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/samuel
GitHub Stars
3
First Seen
13 days ago
Installed on
opencode5
gemini-cli5
codebuddy5
github-copilot5
codex5
kimi-cli5