Controllers
What it is
Section titled “What it is”A controller is a plain Dart class annotated with @Controller. Each method annotated with @Get, @Post and friends becomes a route. The method’s parameters declare what it needs (a path id, a query value, a validated form, a service, the request context) and its return value is the response. The generator reads all of this at build time and writes the wiring, so the method itself contains no request parsing.
When to use it
Section titled “When to use it”- For every HTTP endpoint of a JSON API.
- When you want routes, guards and limits visible in one place, next to the code they protect.
- When you want input binding and validation errors handled the same way everywhere.
When not to use it
Section titled “When not to use it”- For endpoints that need routing features the annotations don’t expose (regex paths, wildcards). Mount a hand-written Shelf handler on a
Routerin a customControllerAttacher, optionally wrapped ingisilaRouteto keep guards and limits. - For behaviour that applies to every request (tenant resolution, tracing). Use middleware.
- For business logic. Keep it in services; a controller method should be a few lines.
How it works
Section titled “How it works”The method signature is the contract:
@Patch('/{id}')@Permissions({'posts:write'})Future<Post> update( String id, // {id} placeholder → path parameter UpdatePostForm form, // Form subtype → parsed + validated body PostService posts, // Service subtype → per-request instance RequestContext ctx, // the request context {bool? notify}, // coercible type, not a placeholder → ?notify= query) => posts.update(id, form, notify: notify ?? false, by: ctx.principal!.id);The generator classifies each parameter in a fixed order (annotation, Request, RequestContext, Form, Service, placeholder name, coercible type, first other parameter as body). See parameter binding rules for the full table.
The return type decides the response: a Response is sent as-is, void is an empty body, anything else is JSON-encoded through toJson() with the annotation’s status code (201 for @Post, 204 for @Delete, otherwise 200).
Guards (@RequireAuth, @Permissions, @AnyPermission, @Roles, @Public) and RouteConfig values are merged at build time into a constant config per route. Nothing is looked up by reflection at runtime.
Controllers are instantiated once, by you, when you register them. That makes them a good home for app-wide dependencies passed to the constructor, and a bad home for anything per request: don’t store request data in fields.
app.registerController(attacher: PostsController(clock: SystemClock()).attachToApp);Controllers vs services
Section titled “Controllers vs services”| Put it in the controller | Put it in a service |
|---|---|
| Route path, verb, status code | Business rules |
| Guards and per-route limits | Database queries and transactions |
| Choosing which form and service to use | Calls to other systems |
Shaping a Response (headers, cookies) |
Anything you’d want to unit-test without HTTP |
Example
Section titled “Example”import 'package:gisila/gisila.dart';
import '../forms/post_forms.dart';import '../models/post.dart';import '../services/post_service.dart';
part 'posts_controller.g.dart';
@Controller('/posts')@RequireAuth()class PostsController { @Get('/') @Public() Future<Page<Post>> list(PostService posts, {int? limit, int? offset}) => posts.list(PageParams(limit: limit, offset: offset));
@Get('/{id}') @Public() Future<Post> show(int id, PostService posts) => posts.get(id);
@Post('/') @Permissions({'posts:write'}) Future<Post> create(CreatePostForm form, PostService posts) => posts.create(form);
@Delete('/{id}') @Permissions({'posts:delete'}) Future<void> delete(int id, PostService posts) => posts.delete(id);}