dart-clean-arch
Skill0 starsChanged 28 days agoMITView on GitHub
Opinionated Dart conventions for medium/large Flutter apps — sealed freezed classes, Entity/Model/Response/Request naming, extensions with Extension suffix, exhaustive pattern matching, strict very_good_analysis. Use when writing entities, models, exceptions, enums or extensions in projects that adopt Clean Architecture.
In this repository28 other files it gives its agents.
- ado-closeSkill
- ado-commentSkill
- ado-getSkill
- ado-refineSkill
- adrSkill
- approve-reviewSkill
- appSkill
- autoSkill
- challenge-specSkill
- completeSkill
- discoverySkill
- fixSkill
- flutter-clean-archSkill
- implementSkill
- jira-refineSkill
- kickoffSkill
- linear-refineSkill
- orcamentoSkill
- planSkill
- post-reviewSkill
- pr-openSkill
- pr-statusSkill
- refineSkill
- reviewSkill
- specifySkill
- statusSkill
- tasksSkill
- verifySkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 5 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 45
Every data class uses freezed sealed/abstract.
- Very specific: line 81
Default annotation: @Freezed(map: FreezedMapOptions.none, when: FreezedWhenOptions.none).
- Distinctive: line 89
Every sum type (result, error, state) uses sealed freezed with named factories.
- Very specific: line 114
Every custom app exception derives from a single sealed tree.
- Distinctive: line 143
Use extension for computed properties on freezed entities/states (which are immutable).
- Very specific: line 148
Use switch expression whenever possible on sealed types.
- Distinctive: line 164
Avoid chained if/else on sealed types.
- Distinctive: line 215
Use sparingly — only for local tuples without strong semantics.
- Very specific: line 226
Every freezed entity/state should be const when possible.
- Very specific: line 228
Prefer const constructors and literals in widgets.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Dart — Clean Architecture conventions
Opinionated Dart conventions for Flutter projects that follow Clean Architecture with data/domain/presentation layers. Pair with the flutter-clean-arch skill for the framework side.
These conventions are opinionated and reflect patterns used in production apps. They are not doctrine — adopt, adapt, or ignore based on your project context.
Assumed stack
- Dart 3.0+ (sealed classes, switch expression, records, pattern matching).
freezed+json_serializablefor data classes.very_good_analysis(or equivalent strict ruleset) withdart analyze --fatal-infos --fatal-warningsin CI.build_runnerfor code generation.
Naming
| Type | Class suffix | File |
| Entity (domain) | none — Task | task.dart |
| Model (data DTO) | Model — TaskModel | task_model.dart |
| Response (API) | Response — TaskResponse | task_response.dart |
| Request (payload) | Request — CreateTaskRequest | create_task_request.dart |
| Interface | I prefix — ITasksRepository | tasks_repository.dart |
| Implementation | no prefix — TasksRepository | tasks_repository.dart |
| Bloc | Bloc — TasksBloc | tasks_bloc.dart |
| Cubit | Cubit — TasksCubit | tasks_cubit.dart |
| UseCase | UseCase — FetchTasksUseCase | fetch_tasks_usecase.dart |
| Page | Page — TaskDetailPage | task_detail_page.dart |
| Extension | Extension — MoneyExtension | money_extension.dart |
| Status enum | Status — TasksStatus (legacy) | tasks_status.dart |
| Domain enum | Enum when ambiguous — PriorityEnum | — |
Critical rules:
- Entity has no suffix.
Task, notTaskEntity. - Model always has
Modelsuffix. - Extension uses
Extensionsuffix, notX(even thoughXis common in the Dart ecosystem). - Interface uses
Iprefix (Effective Dart discourages this — opinionated convention here).
Freezed always
Every data class uses freezed sealed/abstract.
// Entity (domain) — sealed, no suffix, no fromJson
@Freezed(map: FreezedMapOptions.none, when: FreezedWhenOptions.none)
sealed class Task with _$Task {
const factory Task({
required String id,
required String title,
required bool completed,
}) = _Task;
}
// Model (data) — abstract, with fromJson + conversion bridge
@Freezed(map: FreezedMapOptions.none, when: FreezedWhenOptions.none)
abstract class TaskModel with _$TaskModel {
factory TaskModel({
required String id,
required String title,
required bool completed,
}) = _TaskModel;
factory TaskModel.fromJson(Map<String, dynamic> json) =>
_$TaskModelFromJson(json);
factory TaskModel.fromEntity(Task entity) => TaskModel(
id: entity.id,
title: entity.title,
completed: entity.completed,
);
Task toEntity() => Task(id: id, title: title, completed: completed);
}Rules:
- Default annotation:
@Freezed(map: FreezedMapOptions.none, when: FreezedWhenOptions.none). Use the short@freezedonly when intentional — disablingmap/whenforces you to use pattern matching, which is the goal. - Entity is
sealed class. Model isabstract class. - Entity never has
fromJson/toJson— domain doesn't know about serialization. - Model is the bridge:
fromJson(API → Model),toEntity()(Model → Entity),fromEntity()(Entity → Model). - Defaults via named factory (
Task.empty()), not via default parameters.
Sealed unions for sum types
Every sum type (result, error, state) uses sealed freezed with named factories.
@Freezed(map: FreezedMapOptions.none, when: FreezedWhenOptions.none)
sealed class ApiResult<T> with _$ApiResult<T> {
const factory ApiResult.success({required T data}) = _Success<T>;
const factory ApiResult.failure({
required Exception exception,
required StackTrace stackTrace,
}) = _Failure<T>;
}Consumed with pattern matching (preferred in Dart 3+):
return switch (result) {
_Success(:final data) => data.toEntity(),
_Failure(:final exception, :final stackTrace) =>
_exceptionHandler.handle(exception, stackTrace),
};Exceptions: sealed AppException
Every custom app exception derives from a single sealed tree. Mapping status code → exception is the responsibility of a central ExceptionHandler. Repos do NOT know about status codes.
@Freezed(map: FreezedMapOptions.none, when: FreezedWhenOptions.none)
sealed class AppException implements Exception with _$AppException {
factory AppException.network() = NetworkException;
factory AppException.validation({required String message}) = ValidationException;
factory AppException.unauthorized() = UnauthorizedException;
factory AppException.unknown() = UnknownException;
}Extensions
Location: lib/core/extensions/<topic>_extension.dart or in the package where it makes sense.
extension MoneyExtension on int {
String toUSD() => '\$${(this / 100).toStringAsFixed(2)}';
}
extension TaskExtension on Task {
bool get isOverdue =>
dueDate != null && dueDate!.isBefore(DateTime.now()) && !completed;
}Rules:
Extensionsuffix (notX).- Use extension for computed properties on freezed entities/states (which are immutable).
- One file per extended type. Don't group extensions of different types in the same file.
Pattern matching
Use switch expression whenever possible on sealed types. No default: when all cases are covered — exhaustive_cases enforces this.
final color = switch (priority) {
Priority.high => Colors.red,
Priority.medium => Colors.orange,
Priority.low => Colors.green,
};
final widget = switch (state) {
TasksInitial() || TasksLoading() => const LoadingIndicator(),
TasksLoaded(:final tasks) => TasksList(tasks: tasks),
TasksError(:final message) => ErrorView(message: message),
};Avoid chained if/else on sealed types.
Async
Future<T>for I/O operations. NeverFuture<void>when there's a result to propagate.- Explicit
unawaited(...)for fire-and-forget (lint enforcesunawaited_futures). runZonedGuardedinmain()for top-level error capture.- Broadcast streams (
StreamController.broadcast()) for cross-feature signals (session expired, maintenance, connectivity). - No
async*— prefer explicitStreamController.
Imports
prefer_relative_imports: truewithin the same package.package:only cross-package.- Explicit barrel files:
domain.dart,data.dart,presentation.dart,feature.dart.
Strict lint
include: package:very_good_analysis/analysis_options.5.1.0.yaml
analyzer:
strict-casts: true
strict-inference: true
strict-raw-types: true
exclude: ["**/*.g.dart", "**/*.freezed.dart", "**/*.gen.dart"]
linter:
rules:
- exhaustive_cases
- unawaited_futures
- cancel_subscriptions
- close_sinks
- avoid_print
- use_super_parametersavoid_print blocks print() — use a dedicated logger.
Code generation
dart run build_runner build --delete-conflicting-outputs
# or if using melos
melos run genNever edit manually: *.freezed.dart, *.g.dart, *.gen.dart, app_localizations_*.dart.
Records
Use sparingly — only for local tuples without strong semantics. For anything with more than 2 fields OR domain meaning → freezed sealed class.
final (int, int) range = (3, 7); // OK for local use
// DON'T use records to represent domain:
// final ({String id, String title, bool done}) task; // ❌ — make it a TaskConst and immutability
- Every freezed entity/state should be
constwhen possible. - Lists in states:
@Default(<Task>[])(not raw[]— preserves type). - Prefer
constconstructors and literals in widgets.
Dart checklist
- Entity without suffix? Model with
Model? Response withResponse? Request withRequest? - Annotation
@Freezed(map: FreezedMapOptions.none, when: FreezedWhenOptions.none)? - Entity is
sealed class? Model isabstract class? - Entity without
fromJson/toJson? - Model implements
fromJson,fromEntity,toEntity? - Extension with
Extensionsuffix (notX)? - Exhaustive
switchon sealed (no unnecessarydefault)? - Build runner run after changing freezed/json?
dart analyze --fatal-infos --fatal-warningspasses?
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

