
If you lead a Flutter app, or you are about to start one from a template that gives every feature domain/, data/ and presentation/ folders, this post is for you. Clean architecture in Flutter is often taught as the safe default, and this site has taught it that way too. The clean architecture with BLoC guide presents it as the foundation for apps that grow. This post argues the other side, with numbers. The same profile feature was built twice, once in the full layered style and once in the two-layer style that Flutter’s own architecture guide recommends. Both versions then received the same two changes. You will see what each structure cost in files and lines, the bug that only one of them made possible, and the specific conditions under which the extra layers earn their keep.
What Clean Architecture in Flutter Usually Looks Like
Clean architecture in Flutter usually means splitting each feature into three layers. A domain layer holds entities, repository interfaces and use cases. A data layer holds models, data sources and repository implementations. A presentation layer holds widgets and state management. Dependencies point inward, so the domain layer imports nothing from Flutter or the network.
In practice, the recipe that most tutorials teach, including the ones on this site, adds a few more conventions on top of the original idea:
- An entity in the domain layer and a separate model in the data layer, with a mapper between them
- A repository interface in the domain layer and an implementation in the data layer
- A remote data source, also split into an interface and an implementation
- One use case class per operation, each implementing a shared
UseCase<T, Params>base - Either from the
dartzpackage for errors, so every method returnsFuture<Either<Failure, T>> - A get_it service locator that wires everything together
None of these pieces is wrong on its own. The question is what they cost when an app does not need them, and how to tell whether yours does. Robert C. Martin’s original Clean Architecture article is about the dependency rule, not about any particular number of folders. Most of the cost in a Flutter project comes from the conventions listed above, not from that rule.
What Flutter’s Own Architecture Guide Recommends
The Flutter team published an app architecture guide and a page of architecture recommendations, and they are more selective than most tutorials. The guide says an app “should split into two broad layers, the UI layer and the Data layer.” The UI layer holds views and view models. The data layer holds repositories and services.
The domain layer appears too, but as an option. The guide calls it optional “because not all applications or features within an application have these requirements.” It names three conditions for adding a use case: the logic merges data from several repositories, it is exceedingly complex, or several view models reuse it. The recommendations page rates the domain layer as “Conditional” and adds that “in most apps they add unnecessary overhead.”
However, the same page rates two other ideas as “Strongly recommend”: abstract repository classes and dependency injection. So the official position is not “skip the architecture.” Instead, it keeps the repository boundary and drops the layer that sits between it and the view model.
The official Compass sample app follows that advice. Its data/repositories folder has seven repository areas, each with local and remote implementations. Meanwhile, its domain/use_cases folder holds exactly two classes, both for bookings. The larger one, BookingCreateUseCase, takes a destination repository, an activity repository and a booking repository. That is the guide’s first condition, merging data from several repositories, applied as written.
The Experiment: One Feature Built Twice
Opinions about architecture are cheap, so this post measures one thing that can be counted: how much code each structure needs for the same behaviour, and how much of it a change touches.
Measured 2026-10-27
Windows 11 Pro (build 26200), AMD Ryzen 5 8600G, 16 GB RAM
Flutter 3.35.2 (stable), Dart 3.9.0
flutter_bloc 9.1.1, equatable 2.0.7, http 1.5.0
Layered only: get_it 8.2.0, dartz 0.10.1
Two-layer only: none beyond the above
The counts are line and file totals, so they do not change from run to run or between Flutter versions. Each count ran once, and the output is pasted unedited. Both projects passed flutter analyze with no issues and ran the same test suite after every step.
The Feature
The feature is a user profile screen. It loads a profile from GET /users/{id} and shows the name, email and join year. It also lets the user rename themselves with PATCH /users/{id}, under one business rule: a display name must be 2 to 40 characters after trimming, and an invalid name must never reach the API. Errors from the server show up on screen.
Version A: The Layered Recipe
Version A follows the structure from this site’s clean architecture and dependency injection guide: entity, model with toEntity(), repository interface and implementation, remote data source interface and implementation, GetProfile and UpdateDisplayName use cases, Either failures and a get_it container.
lib/
core/error/exceptions.dart
core/error/failures.dart
core/usecase/usecase.dart
features/profile/domain/entities/profile.dart
features/profile/domain/repositories/profile_repository.dart
features/profile/domain/usecases/get_profile.dart
features/profile/domain/usecases/update_display_name.dart
features/profile/data/models/profile_model.dart
features/profile/data/datasources/profile_remote_data_source.dart
features/profile/data/repositories/profile_repository_impl.dart
features/profile/presentation/cubit/profile_cubit.dart
features/profile/presentation/cubit/profile_state.dart
features/profile/presentation/pages/profile_page.dart
injection.dart
main.dart
Version B: The Two-Layer Structure From the Flutter Guide
Version B follows the official guide. A ProfileApi service wraps HTTP, an abstract ProfileRepository has one remote implementation, and the cubit plays the view model. One Profile class carries the JSON parsing and the display-name rule. Dependency injection uses RepositoryProvider, which is built on the provider package that the recommendations page names.
lib/
data/models/profile.dart
data/services/profile_api.dart
data/repositories/profile_repository.dart
data/repositories/profile_repository_remote.dart
ui/profile/profile_cubit.dart
ui/profile/profile_state.dart
ui/profile/profile_page.dart
main.dart
What Was Held Equal, and What Was Not
To isolate the architecture, both versions use the same state management (a Cubit), the same sealed state classes and a page that differs only in how it obtains its cubit. Both also use the same test file shape.
Several limitations apply. First, neither version uses code generation. The site’s own guides add freezed and injectable, which create more files, so the layered numbers below are a floor rather than a ceiling. Second, this is one feature. A real app repeats the per-feature cost, while the shared core/ files are paid once. Third, line counts measure ceremony, not quality, and one author wrote both versions. Treat the numbers as the price of the structure, not as a verdict on it.
What the Layers Cost: Files, Lines and Classes
The counting script looks only at lib/, so tests do not count:
#!/usr/bin/env bash
# Count what one profile feature costs in each structure (lib/ only, tests excluded).
for v in layered two_layer; do
files=$(find "$v/lib" -name '*.dart' | sort)
echo "== $v"
echo "dart files: $(echo "$files" | wc -l)"
echo "total lines: $(cat $files | wc -l)"
echo "non-blank lines: $(cat $files | grep -cv '^[[:space:]]*$')"
echo "classes: $(cat $files | grep -cE '^(abstract |sealed )?class ')"
echo "import lines: $(cat $files | grep -c '^import ')"
echo "packages: $(grep -A20 '^dependencies:' "$v/pubspec.yaml" | grep -cE '^ [a-z_]+: [0-9]')"
done
Raw output for the baseline feature, before any changes:
== layered
dart files: 15
total lines: 424
non-blank lines: 350
classes: 22
import lines: 51
packages: 5
== two_layer
dart files: 8
total lines: 292
non-blank lines: 245
classes: 13
import lines: 23
packages: 3
Clean Architecture in Flutter vs Two Layers at a Glance
| Measure | Layered (A) | Two-layer (B) | Ratio |
|---|---|---|---|
Dart files in lib/ | 15 | 8 | 1.9x |
| Total lines | 424 | 292 | 1.45x |
| Classes | 22 | 13 | 1.7x |
| Import lines | 51 | 23 | 2.2x |
| Packages | 5 | 3 | |
| Classes between button and HTTP call | 4 | 3 |
The headline ratio understates the gap. Per file, the output of wc -l shows where the lines live:
== layered
5 lib/core/error/exceptions.dart
18 lib/core/error/failures.dart
7 lib/core/usecase/usecase.dart
47 lib/features/profile/data/datasources/profile_remote_data_source.dart
33 lib/features/profile/data/models/profile_model.dart
41 lib/features/profile/data/repositories/profile_repository_impl.dart
18 lib/features/profile/domain/entities/profile.dart
13 lib/features/profile/domain/repositories/profile_repository.dart
17 lib/features/profile/domain/usecases/get_profile.dart
37 lib/features/profile/domain/usecases/update_display_name.dart
41 lib/features/profile/presentation/cubit/profile_cubit.dart
31 lib/features/profile/presentation/cubit/profile_state.dart
80 lib/features/profile/presentation/pages/profile_page.dart
27 lib/injection.dart
9 lib/main.dart
424 total
== two_layer
36 lib/data/models/profile.dart
7 lib/data/repositories/profile_repository.dart
22 lib/data/repositories/profile_repository_remote.dart
52 lib/data/services/profile_api.dart
21 lib/main.dart
42 lib/ui/profile/profile_cubit.dart
81 lib/ui/profile/profile_page.dart
31 lib/ui/profile/profile_state.dart
292 total
The page and state files are the same size in both versions, about 110 lines. Setting them aside, the layered version spends 313 lines on everything else, while the two-layer version spends 180. In other words, the non-UI code is 1.74 times larger for identical behaviour.
Where the Extra Lines Go
Most of the difference sits in three places. The first is the use case that forwards a call and does nothing else. Here is GetProfile in full, together with the base class it implements:
// lib/core/usecase/usecase.dart
import 'package:dartz/dartz.dart';
import '../error/failures.dart';
abstract class UseCase<T, Params> {
Future<Either<Failure, T>> call(Params params);
}
// lib/features/profile/domain/usecases/get_profile.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecase/usecase.dart';
import '../entities/profile.dart';
import '../repositories/profile_repository.dart';
class GetProfile implements UseCase<Profile, String> {
const GetProfile(this.repository);
final ProfileRepository repository;
@override
Future<Either<Failure, Profile>> call(String userId) {
return repository.getProfile(userId);
}
}
Seventeen lines, seven import lines across the two files, and one registration in injection.dart, all to call one method. The use case adds no rule, no combination of data and no reuse. As a result, it fails all three of the Flutter guide’s conditions.
The second place is the duplicated shape of the data. Profile and ProfileModel declare the same four fields, and toEntity() copies them one by one. The third is the second interface. The data source has an abstract class with one implementation, sitting behind a repository that also has an abstract class with one implementation.
By contrast, UpdateDisplayName does hold a real rule, the 2 to 40 character check. The two-layer version keeps that rule too, as a static method on Profile, where a test can reach it without any mocks. So the layered version’s genuine domain logic fits in six lines of one file, and the structure around it accounts for the rest.
Change A: Adding One Field
Architecture pays or costs over time, so the baseline matters less than what a change touches. The first change added an optional bio string from the API to the screen. Each version got its own commit, and git diff --stat reported:
== layered: change A (add bio)
lib/features/profile/data/models/profile_model.dart | 4 ++++
lib/features/profile/domain/entities/profile.dart | 4 +++-
lib/features/profile/presentation/pages/profile_page.dart | 1 +
3 files changed, 8 insertions(+), 1 deletion(-)
== two_layer: change A (add bio)
lib/data/models/profile.dart | 5 ++++-
lib/ui/profile/profile_page.dart | 1 +
2 files changed, 5 insertions(+), 1 deletion(-)
Three files against two is a small difference, and it would be dishonest to call it a real cost. The interesting result is a failure mode that only one version has.
The Mapper Line the Compiler Cannot See
The layered version needs the new field in three spots in ProfileModel: the constructor, fromJson and toEntity(). Forgetting the last one is a common slip, and reviewers rarely spot it. To check what catches it, the bio: bio, line was deleted from toEntity(), and then the analyzer and a dedicated mapping test ran:
sed -i '/^ bio: bio,$/d' lib/features/profile/data/models/profile_model.dart
flutter analyze
flutter test test/bio_mapping_test.dart
No issues found! (ran in 1.6s)
00:00 +0: bio survives the model to entity mapping
00:00 +0 -1: bio survives the model to entity mapping [E]
Expected: 'Writes compilers'
Actual: <null>
Which: not an <Instance of 'String'>
package:matcher expect
package:flutter_test/src/widget_tester.dart 473:18 expect
test\bio_mapping_test.dart 14:5 main.<fn>
00:00 +0 -1: Some tests failed.
The analyzer found nothing, because bio is an optional named parameter. The four existing behaviour tests also passed, because none of them checks bio. Only a test written specifically for the mapping caught it. In production, the field would load from the API and then quietly disappear on its way to the screen. The two-layer version cannot have this bug, because its one Profile class goes from JSON to widget with no copy in between.
If you keep separate entities and models, there is a cheap defence. Mark every entity field required in the constructor, including nullable ones (required this.bio on a String? field). Running the same deletion with that change produced a compile error instead of a silent null:
error - The named parameter 'bio' is required, but there's no corresponding argument - lib\features\profile\data\models\profile_model.dart:29:12 - missing_required_argument
1 issue found. (ran in 1.7s)
Change B: Adding One Operation
The second change added a “delete account” action, DELETE /users/{id} returning 204, with a new ProfileDeleted state and a button on the page. This kind of change runs through every layer, so it is where layering costs the most:
== layered: change B (delete account)
.../data/datasources/profile_remote_data_source.dart | 10 ++++++++++
.../data/repositories/profile_repository_impl.dart | 12 ++++++++++++
.../profile/domain/repositories/profile_repository.dart | 2 ++
.../profile/domain/usecases/delete_account.dart | 16 ++++++++++++++++
.../profile/presentation/cubit/profile_cubit.dart | 17 +++++++++++++++++
.../profile/presentation/cubit/profile_state.dart | 4 ++++
.../profile/presentation/pages/profile_page.dart | 6 ++++++
lib/injection.dart | 8 +++++++-
8 files changed, 74 insertions(+), 1 deletion(-)
== two_layer: change B (delete account)
lib/data/repositories/profile_repository.dart | 2 ++
lib/data/repositories/profile_repository_remote.dart | 3 +++
lib/data/services/profile_api.dart | 11 +++++++++++
lib/ui/profile/profile_cubit.dart | 18 +++++++++++++++---
lib/ui/profile/profile_page.dart | 6 ++++++
lib/ui/profile/profile_state.dart | 4 ++++
6 files changed, 41 insertions(+), 3 deletions(-)
One note on fairness: dart format re-wrapped one existing line in the two-layer cubit during this commit. That accounts for two of its changed lines, which works against the two-layer version, not for it.
So the layered version needed 74 new lines in 8 files, and the two-layer version needed 41 in 6. The page, state and view-model edits are nearly identical in both. The difference is a new 16-line use case that forwards one call, another try/catch block in the repository implementation that maps exceptions into Either, and a new get_it registration plus a third constructor argument for the cubit.
Notably, every one of those extra lines is a place where a reviewer has to confirm that nothing happens. A pass-through use case takes a minute to write, but every later reader still has to open it and check that it really is a pass-through.
Does the Domain Layer Make Flutter Testing Easier?
Testability is the most common argument for clean architecture in Flutter. The TDD and clean architecture guide on this site makes that case, and it is a fair one when the alternative is widgets that call http.get directly. However, it is not the alternative here.
Both versions tested the same four behaviours through the same seam: an http.Client replaced by MockClient from package:http/testing.dart. No mocking library was needed in either. The two test files came out the same size, 62 lines each, and the output matched line for line:
== layered
00:00 +0: load emits the parsed profile
00:00 +1: load maps a 500 to an error state
00:00 +2: rename rejects a one-letter name without calling the API
00:00 +3: deleteAccount emits ProfileDeleted on a 204
00:00 +4: All tests passed!
== two_layer
00:00 +0: load emits the parsed profile
00:00 +1: load maps a 500 to an error state
00:00 +2: rename rejects a one-letter name without calling the API
00:00 +3: deleteAccount emits ProfileDeleted on a 204
00:00 +4: All tests passed!
Testability comes from injected boundaries, and both versions have them: the HTTP client and the abstract repository. The extra layers let you write more tests, one per layer with a fake for the layer below. That can help when a layer holds real logic. For a pass-through use case, however, a test proves only that one method calls another. The Flutter guide lists “Testing requires additional mocks” as a cost of use cases, not a benefit.
Where the Layers Pay for Themselves
The numbers above come from a feature with one data source and one small rule. That is typical of many screens, but not all of them. The extra layers start to earn their cost under these conditions:
- Logic that combines several repositories. Creating a booking from a destination, its activities and a user is the Compass app’s example, and it belongs in a use case so that no view model has to coordinate three repositories.
- Rules that several screens share. A pricing or eligibility rule used by a checkout screen, a cart badge and a background job should live in one place, outside any view model.
- A domain shared outside the app. A pure Dart domain package can run in a Dart backend, a CLI or a second app. Teams running Dart on both the client and the server get real reuse from that.
- An API shape that does not match the app’s shape. When the backend returns snake_case blobs, nested envelopes and fields the app never shows, a separate model with a mapper protects the rest of the code from that churn.
- Many teams in one codebase. Separate packages with enforced import rules stop teams from reaching into each other’s internals, which matters more than the per-feature cost once the app has a few dozen contributors.
Notice that most of these are reasons to add a layer to a specific feature, not to the whole app. The ports and adapters explainer makes the same point from the backend side: the boundary matters, and the number of folders around it does not.
When to Use Clean Architecture in Flutter
- Several view models need the same business rule, and duplicating it has already caused a bug
- A feature combines data from two or more repositories before it can show anything
- The domain logic will run outside Flutter, in a Dart server, CLI or shared package
- The API’s data shape differs enough from the app’s that one class cannot serve both cleanly
- Several teams work in one repository and need enforced boundaries between modules
When NOT to Use Clean Architecture in Flutter
- Most screens load data from one repository and display it with little transformation
- The use cases you would write only forward a call to a repository method
- The team is small enough that everyone reads every pull request
- You are building an MVP whose data model will change weekly while you find product fit
- The only argument for the layers is testability, and your repositories are already behind an injected interface
Common Mistakes with Clean Architecture in Flutter
- Writing a use case for every repository method, including ones that only forward the call
- Keeping a separate entity and model when they declare the same fields, which creates mapper bugs with no benefit
- Leaving nullable entity fields optional in the constructor, so a forgotten mapper line compiles
- Adding a data source interface behind a repository interface when each has exactly one implementation
- Depending on
dartzforEitherin new code. Its latest release, 0.10.1, dates from December 2021, and Dart 3’s sealed classes now cover the same need in about twenty lines - Applying the template to every feature on day one, instead of adding a layer where a feature shows a need for it
A Middle Path: Add the Domain Layer When a Feature Earns It
The alternative to full layering is not “no architecture.” Instead, start with the Flutter guide’s two layers and promote individual features as they earn it:
- Start every feature with a view model, an abstract repository with one implementation, and a service for the network or database.
- Keep business rules on the model or in the view model while only one screen uses them.
- Extract a use case when a rule appears in a second view model, or when one action needs a second repository.
- Split the model from an entity only when the API’s shape and the app’s shape diverge, and make every entity field
required. - Move a feature into its own package with enforced imports only when a separate team owns it.
For error handling, a sealed Result type replaces dartz with no dependency. This one passes flutter analyze on Dart 3.9 and follows the same shape as the Compass sample’s result class:
// lib/core/result.dart
// A small replacement for dartz's Either, built on Dart 3 sealed classes.
sealed class Result<T> {
const Result();
const factory Result.ok(T value) = Ok<T>;
const factory Result.error(Exception error) = Error<T>;
}
final class Ok<T> extends Result<T> {
const Ok(this.value);
final T value;
}
final class Error<T> extends Result<T> {
const Error(this.error);
final Exception error;
}
// The compiler checks that a switch over Result handles both cases.
String describe(Result<int> result) => switch (result) {
Ok(:final value) => 'ok $value',
Error(:final error) => 'failed: $error',
};
Because Result is sealed, a switch that misses a case fails to compile, which is the main safety Either offered. Note that the Error class shadows dart:core‘s Error in files that import it. If that bothers your team, rename it Failure or Err.
Folder layout is a separate decision from layering. A feature-first layout, covered in the feature-first folder structure guide, works with either approach and makes promoting one feature to more layers a local change.
Real-World Scenario: Collapsing an Inherited Template
Consider a common situation: a small team of three or four developers inherits a mid-sized Flutter app with somewhere between 30 and 40 screens. It started from a clean architecture template, so every feature has a domain folder, and most use cases forward a single call. New screens take noticeably longer to add than the team expects, and new hires ask why four files change to add one API field.
A full rewrite would be a mistake, because the repositories and their interfaces are worth keeping. A cheaper approach is to sort the use cases into two groups over a few sprints. Pass-through use cases get inlined into the view models that call them, one feature at a time, with the existing tests run after each step. Use cases that combine repositories or hold rules stay where they are. Entities that mirror their models one to one get merged into a single class.
The trade-off is consistency. After the cleanup, some features have a domain layer and some do not, and new developers need a short written rule for when to add one. The middle-path checklist above works as that rule. Even so, a short written rule is easier to explain to a new hire than a template that every feature has to work around.
Is Clean Architecture Overkill for Flutter?
For most Flutter features, the full recipe is overkill, and Flutter’s own documentation says the same about use cases. The parts worth keeping everywhere are an abstract repository and dependency injection, which the official guide rates “Strongly recommend.” The domain layer, separate entities and per-operation use cases are worth adding for features with shared rules, several repositories or reuse outside the app.
If you are comparing state management choices at the same time, keep them separate from this decision. Both versions here used the same Cubit, and the Riverpod vs Bloc rebuild counts are a different question with its own measurements.
Conclusion
Clean architecture in Flutter is a set of tools, not a default. On one profile feature with identical behaviour and identical tests, the full layered recipe needed 15 files and 424 lines against 8 files and 292 lines for the two-layer structure. Adding one operation took 74 new lines against 41. It also made possible a mapper bug that the analyzer could not see. Use the Flutter guide’s two layers with an abstract repository as your starting point, and add a domain layer to the features that show one of the conditions above.
As a next step, open the use cases folder in your current app and count how many classes only forward a call. Those are the first candidates to inline. Then read the clean architecture with BLoC guide again with this post’s conditions in mind. The structure it describes is sound for the features that need it, and the job is deciding which features those are.