Skip to content

Controllers

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.

  • 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.
  • For endpoints that need routing features the annotations don’t expose (regex paths, wildcards). Mount a hand-written Shelf handler on a Router in a custom ControllerAttacher, optionally wrapped in gisilaRoute to 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.

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);
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
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);
}