Common Patterns
This guide collects the Flint patterns that appear across most applications. Use it after Getting Started and before editing app code when the task is not tied to one narrow topic.
Before coding, inspect the local app:
lib/main.dartfor theFlint(...)app, global middleware, routes, static assets, andlisten(...).lib/routes/forRouteGroupclasses.lib/controllers/for request-scoped controllers.lib/models/forModel<T>classes andTabledefinitions.lib/config/database_api.dartfor Database API resources, if the app exposes model resources.lib/seeders/andlib/config/seeder_registry.dartfor seed data.lib/jobs/andlib/config/jobs_registry.dartfor queue jobs, workers, and schedules.lib/isolate/for CPU-heavy or blocking isolate tasks.lib/middlewares/for guards and request pipeline behavior.- Logging before adding request logs, job logs, error logs, or committed logging calls.
- Testing before adding route, controller, middleware, validator, storage, job, seeder, or UI component tests.
lib/config/ai.dart,lib/ai/, and AI Runtime before changing AI providers, agents, tools, workflows, memory, or persistence.- Sessions And Cookies before changing sessions, cookies, flash messages, or browser auth session storage.
public/and Storage for public uploaded files.lib/services/orlib/actions/for business workflows.lib/mail/,lib/mail/views/, Mail, and Templates when the feature sends email or renders HTML templates.lib/ui/and UI Widgets for Flint fullstack frontend code, components, forms, buttons, layouts, overlays, tables, charts, storage, navigation, and state.- Build And Rendering before changing
flint build,flint web, browser entrypoints, generated bundles, page registry behavior, or SSR. - Deployment before changing Docker, production startup, environment variables, deploy scripts, or worker process setup.
- Routing, Middleware, Logging, Testing, Validation, Models And Database, Database API, AI Runtime, Seeders, Jobs And Workers, Isolate Tasks, Sessions And Cookies, Templates, Cache, Storage, Security And Utilities, Frontend UI, UI Widgets, Build And Rendering, and Deployment when the task touches those areas.
One Reusable Thing Per File
The most important Flint application pattern is simple: every meaningful, reusable thing gets its own Dart file.
This applies to backend and frontend code:
- models
- controllers
- route groups
- middleware
- actions and services
- queue jobs
- isolate tasks
- DTOs, resources, presenters, and view models
- policies, validators, and exceptions
- mail classes
- Flint UI pages
- Flint UI sections
- Flint UI components
- state holders and browser helpers
- top-level helper functions that return
View,Node,FlintNode, orFlintComponent
Use snake_case file names that match the class or helper responsibility:
lib/models/course.dart
lib/controllers/course_controller.dart
lib/routes/course_routes.dart
lib/services/courses/create_course_action.dart
lib/services/courses/publish_course_action.dart
lib/middlewares/role_middleware.dart
lib/config/ai.dart
lib/ai/agents/ticket_triage_agent.dart
lib/ai/tools/ticket_summary_tool.dart
lib/ai/workflows/support_reply_workflow.dart
lib/jobs/send_course_created_mail_job.dart
lib/isolate/tasks/generate_course_report_task.dart
lib/mail/course_created_mail.dart
lib/ui/pages/courses_page.dart
lib/ui/sections/course_list_section.dart
lib/ui/components/course_card.dart
lib/ui/helpers/course_empty_state.dart
Do not add a second class, component, section, or reusable helper to an existing file because the new thing feels small. Small files are good when each file has one reason to change.
Private Helper Methods
Do not hide feature behavior inside private methods on controllers, pages, or large classes.
Avoid this shape:
class CourseController extends Controller {
Future<Response> store() async {
final data = await req.validate({'title': 'required|string'});
final course = await _createCourse(data);
await _sendCourseCreatedMail(course);
return res.status(201).json({'data': course});
}
Future<Course> _createCourse(Map<String, dynamic> data) async {
final course = await Course().create(data);
if (course == null) {
throw Exception('Course could not be created');
}
return course;
}
Future<void> _sendCourseCreatedMail(Course course) async {}
}
Prefer named classes in their own files:
File: lib/services/courses/createcourseaction.dart
import '../../models/course.dart';
class CreateCourseAction {
Future<Course> call(Map<String, dynamic> data) async {
final course = await Course().create(data);
if (course == null) {
throw Exception('Course could not be created');
}
return course;
}
}
File: lib/services/courses/sendcoursecreatedmailaction.dart
import '../../mail/course_created_mail.dart';
import '../../models/course.dart';
class SendCourseCreatedMailAction {
Future<void> call(Course course) async {
await CourseCreatedMail(course).send();
}
}
File: lib/controllers/course_controller.dart
import 'package:flint_dart/flint_dart.dart';
import '../services/courses/create_course_action.dart';
import '../services/courses/send_course_created_mail_action.dart';
class CourseController extends Controller {
Future<Response> store() async {
final data = await req.validate({'title': 'required|string'});
final course = await CreateCourseAction().call(data);
await SendCourseCreatedMailAction().call(course);
return res.status(201).json({'data': course});
}
}
The controller now reads like a workflow. The workflow steps can be tested, reused, replaced, or read without digging through private helpers.
Thin Route Groups
Use route groups to describe the URL surface. Keep business behavior in controllers and actions.
File: lib/routes/course_routes.dart
import 'package:flint_dart/flint_dart.dart';
import '../controllers/course_controller.dart';
import '../middlewares/auth_middleware.dart';
class CourseRoutes extends RouteGroup {
@override
String get prefix => '/courses';
@override
String get tag => 'Courses';
@override
void register(Flint app) {
final courses = app.controller(CourseController.new);
courses.get('/', (controller) => controller.index());
courses.post('/', (controller) => controller.store())
.useMiddleware(AuthMiddleware());
courses.get('/:id', (controller) => controller.show());
courses.patch('/:id', (controller) => controller.update())
.useMiddleware(AuthMiddleware());
courses.delete('/:id', (controller) => controller.destroy())
.useMiddleware(AuthMiddleware());
}
}
Route groups should not contain database workflows. They should register routes, prefixes, tags, and middleware.
Request-Scoped Controllers
Feature controllers should extend Controller. Flint binds the current Context for each request when the route is registered with app.controller(...).
File: lib/controllers/course_controller.dart
import 'package:flint_dart/flint_dart.dart';
import '../models/course.dart';
import '../services/courses/create_course_action.dart';
import '../services/courses/update_course_action.dart';
class CourseController extends Controller {
Future<Response> index() async {
final page = int.tryParse(req.queryParam('page') ?? '1') ?? 1;
final courses = await Course()
.orderBy('created_at', desc: true)
.paginate(page, 20);
return res.json(courses);
}
Future<Response> store() async {
final data = await req.validate({
'title': 'required|string|min:3',
'status': 'in:draft,published',
});
final course = await CreateCourseAction().call(data);
return res.status(201).json({'data': course});
}
Future<Response> show() async {
final course = await Course().find(req.param('id'));
if (course == null) {
return res.status(404).json({'message': 'Course not found'});
}
return res.json({'data': course});
}
Future<Response> update() async {
final input = await req.validate({
'id': 'required|string',
'title': 'string|min:3',
'status': 'in:draft,published',
});
final data = {
if (input.containsKey('title')) 'title': input['title'],
if (input.containsKey('status')) 'status': input['status'],
};
final course = await UpdateCourseAction().call(input['id'], data);
return res.json({'data': course});
}
Future<Response> destroy() async {
await Course().delete(req.param('id'));
return res.status(204).send('');
}
}
Inside a controller:
contextis the boundContext.reqis the request.resis the response for HTTP actions.socketis the WebSocket for WebSocket actions.read<T>()reads typed context data written by middleware.write<T>(value)writes typed context data for later code.
Small Inline Routes
Inline Context routes are fine for small framework endpoints, health checks, previews, and simple redirects.
app.get('/health', (Context ctx) {
return {'ok': true};
});
app.get('/preview/email/otp', (Context ctx) {
return ctx.res?.renderEmail(PreviewOtpMail());
});
When a route starts validating input, touching models, sending mail, or making several decisions, move it into a controller and action classes.
Request Input
Use the narrowest request helper that matches the source of the data.
final id = req.param('id');
final page = req.queryParam('page');
final body = await req.json();
final form = await req.form();
final file = await req.file('avatar');
final input = await req.allInput();
Common request helpers:
req.param('id')reads a route parameter.req.queryParam('page')reads a URL query parameter.req['id']checks route params first, then query params.req.input('email')reads one value from normalized input.req.allInput()merges query, body/form fields, files, and route params.req.body()returns the raw body as text.req.rawBody()returns cached raw bytes for custom decoders or signature checks.req.json()parses a JSON object body.req.form()parses URL-encoded or multipart form fields.
req.get('key') is request-scoped storage, not query input. Use req.queryParam(...), req.input(...), or req.allInput() for incoming data.
Validation
Prefer req.validate(...) before using user input.
final data = await req.validate({
'email': 'required|email',
'password': 'required|string|min:8|confirmed',
});
validate(...) reads normalized input. It can validate route params, query params, JSON body fields, form fields, and uploaded files. It throws ValidationException; ExceptionMiddleware turns that into a JSON response for HTTP requests.
Because route params are part of normalized input, an /:id route that calls req.validate(...) should include id in its rules.
Use app-specific validator classes when validation rules are reused or become long.
File: lib/validators/createcoursevalidator.dart
class CreateCourseValidator {
Map<String, String> get rules => {
'title': 'required|string|min:3',
'status': 'in:draft,published',
};
}
Controller usage:
final data = await req.validate(CreateCourseValidator().rules);
Responses
Use response instance methods from ctx.res or controller res.
return res.json({'ok': true});
return res.status(201).json({'data': course});
return res.status(404).json({'message': 'Not found'});
return res.send('hello');
return res.redirect('/login');
return res.back(fallback: '/');
return res.view('emails.welcome', data: {'name': 'Ada'});
return res.page('Dashboard', props: {'title': 'Dashboard'});
Handlers may also return data directly:
app.get('/health', (Context ctx) {
return {'ok': true};
});
Returned Model instances and objects with toMap() or toJson() are sent as JSON. Other values are written through the response instance's respond(...) behavior, which infers JSON, HTML, or plain text.
After res.send(...), res.json(...), res.respond(...), res.view(...), or res.page(...), the response is considered handled. Return it and do not write to the response again later.
JSON Resource Objects
When a response shape is reused, create a small resource or presenter class in its own file. Do not rebuild the same map in many controllers.
File: lib/resources/course_resource.dart
import '../models/course.dart';
class CourseResource {
CourseResource(this.course);
final Course course;
Map<String, dynamic> toMap() {
return {
'id': course.id,
'title': course.title,
'status': course.status,
'createdAt': course.createdAt?.toIso8601String(),
};
}
}
Controller usage:
return res.json({'data': CourseResource(course).toMap()});
For simple one-off responses, inline maps are fine. Extract a resource when the shape is reused by several routes, needs computed fields, or conceals sensitive attributes.
Models In JSON
The response instance method res.json(...) recursively converts:
DateTimeto ISO stringsModeltotoMap()- lists and maps recursively
- custom objects through
toMap()ortoJson()when present Exceptionvalues to an error map
This is why a controller can return model results directly:
final courses = await Course().orderBy('created_at', desc: true).get();
return res.json({'data': courses});
If a field should not be exposed, conceal it on the model or return a resource object with the exact API shape.
Model Query Patterns
Use model methods for normal CRUD:
final course = await Course().find(id);
final firstDraft = await Course().where('status', 'draft').first();
final published = await Course().where('status', 'published').get();
final page = await Course().orderBy('created_at', desc: true).paginate(1, 20);
final count = await Course().where('status', 'published').count();
Creation:
final course = await Course().create({
'title': data['title'],
'status': data['status'] ?? 'draft',
});
Update by primary key:
final course = await Course().update(
id: id,
data: {
'title': data['title'],
'status': data['status'],
},
);
Delete by primary key:
await Course().delete(id);
Use whereOperator, whereIn, whereNull, whereLike, whereContains, and the matching orWhere... helpers when a query needs more than equality.
Database Safety
Prefer model query helpers or parameterized DB.query(...).
await DB.query(
'SELECT * FROM users WHERE email = :email',
namedParams: {'email': email},
);
Do not interpolate untrusted request input into SQL strings.
Avoid this:
await DB.query("SELECT * FROM users WHERE email = '$email'");
AntiSqlInjectionMiddleware is only a defense-in-depth tripwire. It does not replace parameterized queries, authorization, validation, or model constraints.
Database API Resources
Use FlintDatabaseApi only when the app intentionally exposes a model as a resource. A resource should declare its operations, writable fields, hidden fields, and policies clearly.
final api = FlintDatabaseApi(
config: FlintDatabaseApiConfig(
auth: const FlintDbAuth.enabled(defaultRole: 'user'),
),
resources: [
Course.new.resource.readOnly(),
],
);
app.databaseApi(api);
Do not assume a model is public because it exists. Do not expose sensitive columns through generic CRUD. Read Database API before adding or changing Database API resources.
Context Extras
Use Context extras when middleware computes data that later code needs.
File: lib/context/current_tenant.dart
class CurrentTenant {
CurrentTenant(this.id);
final String id;
}
File: lib/middlewares/tenant_middleware.dart
import 'package:flint_dart/flint_dart.dart';
import '../context/current_tenant.dart';
class TenantMiddleware extends Middleware {
@override
Handler handle(Handler next) {
return (Context ctx) async {
final tenantId = ctx.req.headers['x-tenant'] ?? 'default';
ctx.write(CurrentTenant(tenantId));
return await next(ctx);
};
}
}
Controller usage:
final tenant = read<CurrentTenant>();
return res.json({'tenant': tenant?.id});
Use ctx.write<T>(...) and ctx.read<T>() for typed data. Use ctx.setExtra and ctx.getExtra only when type keys are not enough.
Auth Checks
For protected routes, prefer middleware. Let middleware load the user once and write it into Context.
File: lib/middlewares/auth_middleware.dart
import 'package:flint_dart/flint_dart.dart';
class AuthMiddleware extends Middleware {
@override
Handler handle(Handler next) {
return (Context ctx) async {
final res = ctx.res;
if (res == null) {
return await next(ctx);
}
final user = await ctx.req.user;
if (user == null) {
return res.status(401).json({'message': 'Unauthorized'});
}
ctx.write<Map<String, dynamic>>(user);
return await next(ctx);
};
}
}
Controller usage:
final user = read<Map<String, dynamic>>();
Important: req.isAuthenticated only checks whether req.user has already cached a user on the request. Call await req.user first, or let auth middleware do it.
Read Authentication before adding login, register, current-user, refresh token, password reset, send OTP, verify OTP, or resend OTP behavior. Read Sessions And Cookies before adding login cookies, server sessions, flash messages, or browser auth session storage.
File Uploads
Multipart uploads become UploadedFile objects.
if (!await req.hasFile('avatar')) {
return res.status(422).json({'message': 'Avatar is required'});
}
final upload = await req.file('avatar');
Use Storage when the app stores public files and needs public URLs:
final avatarUrl = await Storage.create(
upload!,
subdirectory: 'uploads/avatars',
);
Read Storage before adding public file storage.
Use req.storeFile(...) when a saved filesystem path is enough:
final path = await req.storeFile(
'avatar',
directory: 'public/uploads/avatars',
);
Use req.files(...), req.hasFiles(...), req.allFiles(), and req.storeFiles(...) for multi-file fields.
Always validate:
- the user is allowed to upload
- the field exists
- file size
- file extension and MIME type
- storage directory
- whether the old file should be deleted or replaced
Mail And OTP Workflows
Controllers should not build raw SMTP messages. Put mail in a ViewMailable class and call it from an action.
File: lib/services/auth/sendauthotp_action.dart
import '../../mail/otp_verification_mail.dart';
class SendAuthOtpAction {
Future<void> call({
required String email,
required String otp,
}) async {
await OTPVerificationMail(
recipientEmail: email,
otp: otp,
).send();
}
}
Controller usage:
final otp = await Auth.generateNumericVerificationCode(email);
await SendAuthOtpAction().call(email: email, otp: otp);
For password reset OTPs, use the password reset auth helpers described in Authentication. For email templates and {{ ... }} syntax, read Mail and Templates.
Middleware Pattern
Middleware should either pass through with await next(ctx) or return a response to stop the pipeline.
class ApiKeyMiddleware extends Middleware {
ApiKeyMiddleware(this.expectedKey);
final String expectedKey;
@override
Handler handle(Handler next) {
return (Context ctx) async {
final res = ctx.res;
if (res == null) {
return await next(ctx);
}
if (ctx.req.headers['x-api-key'] != expectedKey) {
return res.status(401).json({'message': 'Invalid API key'});
}
return await next(ctx);
};
}
}
Global middleware can run for WebSocket contexts, so check ctx.res before using response methods. See Middleware for order and built-ins.
Logging Pattern
Use Flint's Log helpers instead of committed print(...) calls:
Log.info('Course published', tag: 'courses');
Log.warning('Invalid API key attempt ip=${req.clientIpAddress}', tag: 'security');
When catching an exception, include the error and stack trace once, then return a deliberate response or rethrow so ExceptionMiddleware can handle it:
try {
await PublishCourseAction().call(req.param('id'));
} catch (error, stack) {
final id = req.param('id');
Log.error(
'Course publish failed id=$id',
tag: 'courses',
error: error,
stackTrace: stack,
);
rethrow;
}
Use await ctx.log(...) inside QueueJob handlers for progress that belongs to that job record. Use Log.*(...) for worker process logs.
Read Logging before adding log configuration, custom request logs, job logs, or error reporting. Do not log cookies, authorization headers, raw request bodies, passwords, OTP values, session IDs, or tokens.
Frontend UI Pattern
Flint is fullstack. Frontend source belongs in lib/ui.
Use one component, section, page, state holder, or reusable UI helper per file. Do not hide UI pieces inside private page methods.
Avoid this:
class CoursesPage extends StatelessComponent {
@override
View build() {
return Column(children: [
_header(),
_courseCard('Intro to Dart'),
]);
}
View _header() => PageHeader(title: 'Courses');
View _courseCard(String title) => Card(child: Text(title));
}
Prefer this file layout:
lib/ui/pages/courses_page.dart
lib/ui/components/course_header.dart
lib/ui/sections/course_list_section.dart
lib/ui/components/course_card.dart
File: lib/ui/pages/courses_page.dart
import 'package:flint_dart/ui.dart';
import '../components/course_header.dart';
import '../sections/course_list_section.dart';
class CoursesPage extends StatelessComponent {
@override
View build() {
return Column(
children: [
CourseHeader(),
CourseListSection(),
],
);
}
}
Any extracted method or function that returns View, Node, FlintNode, or FlintComponent should become a standalone component or helper file when it represents a reusable UI piece.
Error Handling
Let ExceptionMiddleware handle common framework exceptions:
ValidationExceptionAuthExceptionForbiddenExceptionBaseException- format, timeout, argument, and database exceptions
Read Security And Utilities before adding hashing, JWT helpers, rate-limit middleware, custom exceptions, or Str helpers.
Inside app code, prefer clear domain exceptions or response branches.
final course = await Course().find(req.param('id'));
if (course == null) {
return res.status(404).json({'message': 'Course not found'});
}
Do not catch broad exceptions just to return generic responses unless the route needs custom recovery. Let the global exception middleware produce the framework error shape.
Generated Output
Treat these as generated or derived output:
docs/swagger.jsonpublic/assets/js/flint-ui/public/assets/css/flint-ui/public/flint-sw.js- generated API HTML under
doc/api/
Do not hand-edit generated output when the source file can be fixed instead.
Common Mistakes
- Do not put multiple classes in one Dart file.
- Do not add reusable behavior as private
_someThing()methods. - Do not put business workflows in route closures.
- Do not instantiate one controller and reuse it across requests; use
app.controller(YourController.new). - Do not use
req.get(...)for query or body input. - Do not rely on
req.isAuthenticatedbeforeawait req.user. - Do not write to
resafterres.send(...),res.json(...),res.respond(...),res.view(...), orres.page(...). - Do not attach route middleware with
.use(...); use.useMiddleware(...). - Do not put frontend pages, sections, components, and UI helpers in the same file.
- Do not interpolate request input into SQL.
- Do not expose models through
FlintDatabaseApiwithout explicit operations, field rules, and policies. - Do not use
print(...)for committed application logs; useLog.*(...). - Do not log cookies, tokens, OTPs, passwords, authorization headers, or raw request bodies.
- Do not hand-edit generated docs or built frontend assets.
Implementation Checklist
Before finishing a feature:
- The route group only registers routes, middleware, prefixes, and tags.
- The controller extends
Controllerand uses boundreq,res, andcontext. - Each workflow step that has a name is an action/service in its own file.
- Each class, middleware, mail class, model, route group, and UI component has its own file.
- Request data is validated with
req.validate(...). - Auth and role checks live in middleware or clearly named policy/action files.
- Models use Flint query helpers or parameterized SQL.
- Responses use
res.json(...),res.status(...).json(...),res.send(...),res.view(...), orres.page(...). - Frontend UI under
lib/uiis split into pages, sections, components, and helpers. - Logging uses
Log.*(...)or jobctx.log(...)and does not include secrets. - The right topic docs were read before coding.
One language powering Full-Stack Web, Cross-Platform Clients, Native AI, and Connected Robotics.