Dart clean arch
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.From its SKILL.md
npx -y skills add GustavoAlecio/claude-setup --skill dart-clean-archAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
8.6 KB, ~2.1k tokens by cl100k_base, as published. Nobody here has run it
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_parameters
avoid_print blocks print() — use a dedicated logger.
Code generation
dart run build_runner build --delete-conflicting-outputs
# or if using melos
melos run gen
Never 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 Task
Const 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?
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.