Building Scalable Flutter Apps with Clean Architecture
Layering a Flutter codebase so it survives growth - entities, use cases, repositories, typed failures, and the dependency rule that holds them together.
Every Flutter app starts the same way. One main.dart, a handful of widgets, an
http call inline in initState, and it works. It keeps working for weeks.
Then a second developer joins. Then the API changes shape. Then someone asks whether the app can run against a mock backend for a demo, and you discover the networking code is welded into forty widgets. Nothing broke - the app simply became expensive to change, which is the same problem as a bug, measured in days instead of crashes.
Clean Architecture is one answer. It is not the only one, and it is actually overkill for a weekend project. But for an app that several people will work on for years, the discipline pays for itself. This post covers how I structure it in Flutter, what each layer is actually for, how the pieces are tested, and where I deliberately deviate from the textbook.
The dependency rule is the whole idea
Strip away the concentric-circle diagrams and Clean Architecture reduces to one constraint:
Source code dependencies point inward, toward business logic, and never outward toward frameworks.
Three layers, ordered from the inside out:
- Domain - entities and use cases. Pure Dart. It imports nothing from Flutter, nothing from your HTTP client, nothing from your database.
- Data - repository implementations, remote and local data sources, DTOs. It depends on domain.
- Presentation - widgets, state management, navigation. It depends on domain, and never reaches into data directly.
The test for whether you have got it right is blunt: open any file in your
domain folder and look at its imports. If package:flutter/... appears, the
layering is already broken. Some teams enforce this mechanically with a lint
rule; on smaller teams a code-review habit is enough, as long as everyone
understands why the rule exists rather than just that it exists.
That inward-only rule is what makes the outer layers replaceable. Anything the domain does not import, you can swap without touching business logic.
Why the domain layer must be pure Dart
Keeping domain free of Flutter is not purity for its own sake. It buys three concrete things.
Speed of testing. Domain tests run on the Dart VM in milliseconds, with no
widget tree, no WidgetTester, no pumpAndSettle. A full suite of business-rule
tests finishes faster than a single integration test boots. Tests that fast get
run constantly; tests that take two minutes get run at the end, or not at all.
Portability. The same domain package can back a Flutter app, a CLI tool, and a server-side Dart service unchanged. If you ever build an admin tool or a migration script, the pricing rules are already written and already tested.
Findability. Most importantly, it forces the question what does this app
actually do? to be answered somewhere other than inside a widget. An Order
entity that knows how to price itself is a rule you can locate and change. The
same logic spread across three build methods is folklore - technically
present, practically unfindable.
// domain/entities/order.dart - pure Dart, no Flutter imports
class Order {
const Order({required this.items, this.discount});
final List<LineItem> items;
final Discount? discount;
Money get subtotal => items.fold(Money.zero, (sum, i) => sum + i.total);
Money get total => discount?.applyTo(subtotal) ?? subtotal;
bool get isEmpty => items.isEmpty;
}There is no fromJson here, no BuildContext, no formatting. Money is a value
object rather than a double, because currency arithmetic in floating point is
a bug waiting for a rounding edge case. Those decisions are cheap to make while
the class is this small, and expensive to retrofit later.
Use cases name the verbs
Entities are the nouns; use cases are the verbs. Each is a single class with a single public method, representing one thing a user can do.
class PlaceOrder {
const PlaceOrder(this._orders);
final OrderRepository _orders;
Future<Result<OrderConfirmation>> call(Order order) async {
if (order.isEmpty) {
return Result.failure(EmptyCartFailure());
}
if (order.total.isNegative) {
return Result.failure(InvalidDiscountFailure());
}
return _orders.submit(order);
}
}Three things are worth noticing.
The constructor takes OrderRepository, which is an abstract class defined in
domain - not the concrete implementation from the data layer. Domain declares
the shape of what it needs; data satisfies it. This is dependency inversion, and
it is the mechanism that lets the arrow point inward.
The validation lives here rather than in the widget, so it holds no matter which screen triggers a checkout. When a second entry point appears - a deep link, a "reorder" button, a background retry - the rule comes along for free.
Naming the method call lets the class be invoked like a function
(await placeOrder(order)), which reads well at the call site while keeping the
testability of a class.
The single-method shape looks like ceremony at first. Its value shows up when
you open the project after six months: use_cases/ is a literal, browsable list
of everything the application can do.
Making the layers concrete in Flutter
Theory is cheap. Here is how it lands as folders and classes.
Folder structure by feature, then by layer
Layer-first structure - data/, domain/, presentation/ at the root - looks
tidy in an example and falls apart at scale. Working on one feature means
touching three distant folders, merge conflicts concentrate in shared
directories, and nothing in the tree tells a newcomer what the product does.
Feature-first, layer-second reads far better:
lib/
├── core/ # shared across features
│ ├── error/failures.dart
│ ├── network/dio_client.dart
│ ├── di/injection.dart
│ └── result.dart
└── features/
└── orders/
├── domain/
│ ├── entities/order.dart
│ ├── repositories/order_repository.dart # abstract
│ └── use_cases/place_order.dart
├── data/
│ ├── models/order_model.dart # DTO + JSON
│ ├── datasources/order_remote_ds.dart
│ ├── datasources/order_local_ds.dart
│ └── repositories/order_repository_impl.dart
└── presentation/
├── bloc/orders_bloc.dart
└── pages/orders_page.dart
A developer opening features/ sees the product, not the pattern. Deleting a
feature means deleting one folder and one DI registration. And because each
feature owns its own three layers, two people working on different features
rarely touch the same files.
core/ is the pressure valve - things shared across features live
there. It is also the folder most likely to rot, so it is worth being strict:
if only one feature uses something, it belongs to that feature.
Models are not entities
The most common shortcut is letting the API response object be the domain
entity: one class with fromJson on it, used from the network layer all the way
into widgets.
It works right up until the backend renames a field. Then the rename propagates through every widget that touched it, because the wire format and the business model were the same type. You are not changing your app because your product changed; you are changing it because someone else's JSON changed.
Keeping them separate puts a single translation seam at the boundary:
class OrderModel {
const OrderModel({required this.id, required this.lineItems});
final String id;
final List<LineItemModel> lineItems;
factory OrderModel.fromJson(Map<String, dynamic> json) => OrderModel(
id: json['order_id'] as String, // wire naming stops here
lineItems: (json['line_items'] as List)
.map((e) => LineItemModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
factory OrderModel.fromEntity(Order order) => OrderModel(
id: '',
lineItems: order.items.map(LineItemModel.fromEntity).toList(),
);
Order toEntity() => Order(
items: lineItems.map((m) => m.toEntity()).toList(),
);
}When order_id becomes orderId, exactly one line changes. That is the entire
return on the extra class, and it is worth it the first time it happens.
The same seam absorbs the other realities of real APIs: a field that arrives as
a string but should be an enum, a timestamp in seconds that the domain wants as
a DateTime, a nullable field the backend swears is never null. All of that
coercion belongs in the model, so the entity can state what is true rather than
what the wire happens to send.
Repositories invert the dependency
The domain declares what it needs; the data layer supplies it. This is what allows the inner layer to stay ignorant of HTTP, SQLite, and Firebase:
// domain - the contract
abstract class OrderRepository {
Future<Result<OrderConfirmation>> submit(Order order);
Future<Result<List<Order>>> recent();
}// data - the implementation
class OrderRepositoryImpl implements OrderRepository {
OrderRepositoryImpl(this._remote, this._local, this._connectivity);
final OrderRemoteDataSource _remote;
final OrderLocalDataSource _local;
final Connectivity _connectivity;
@override
Future<Result<OrderConfirmation>> submit(Order order) async {
if (!await _connectivity.isOnline) {
await _local.queue(OrderModel.fromEntity(order));
return Result.failure(QueuedOfflineFailure());
}
try {
final model = await _remote.submit(OrderModel.fromEntity(order));
return Result.success(model.toEntity());
} on ServerException catch (e) {
return Result.failure(ServerFailure(e.message));
} on TimeoutException {
return Result.failure(TimeoutFailure());
}
}
}Note where exceptions die. Data-layer code throws freely; the repository catches
and converts to a typed Failure. Nothing above this line ever handles a
SocketException, which means no widget needs a try/catch around business
logic, and no error path depends on remembering that a particular call can throw.
This is also where cross-cutting concerns land naturally. Offline queueing, cache-then-network, retry with backoff - all of it lives in the repository, invisible to both the use case above and the data sources below.
Typed failures instead of thrown exceptions
Result is a small sealed type that makes failure part of the signature:
sealed class Result<T> {
const Result();
factory Result.success(T value) = Success<T>;
factory Result.failure(Failure failure) = FailureResult<T>;
}With Dart 3's exhaustive switch, the compiler stops you forgetting a branch:
switch (await placeOrder(order)) {
case Success(value: final confirmation):
emit(OrdersConfirmed(confirmation));
case FailureResult(failure: final f):
emit(OrdersError(f.userMessage));
}An exception is invisible in a method signature; a Result is not. If you would
rather not hand-roll it, dartz's Either or the result_dart package do the
same job.
What this buys you, and what it costs
The benefit that gets advertised is testability, and it is real. Use cases test against a mock repository with no Flutter binding:
test('rejects an empty cart without calling the repository', () async {
final repo = MockOrderRepository();
final placeOrder = PlaceOrder(repo);
final result = await placeOrder(const Order(items: []));
expect(result, isA<FailureResult>());
verifyNever(() => repo.submit(any()));
});No setUp with a widget tree, no pump, no mocked BuildContext. It runs in
single-digit milliseconds, so the rule is covered permanently for almost no cost.
The benefit that matters more in practice is swappability. Moving from REST to GraphQL touches the data layer only. Adding offline caching means a new local data source and a changed repository - no widget, no use case, no entity moves. Running the app against fixtures for a demo means registering a different repository implementation at startup. That is the difference between a migration you schedule and one you dread.
The cost is real too, and worth stating plainly. A single feature means three folders and often six files where one would do. A trivial read - fetch a list, show it - passes through a data source, a model, a repository, a use case, and a bloc before reaching a widget. For a prototype that is friction with no payoff, and I do not use this structure for small apps.
My rough break-even: more than one developer, or an expected lifetime beyond a
few months. Below that, a services/ folder and honest naming will serve you
better than five layers.
How to structure a scalable Flutter application
The five structural decisions, in the order you make them. Get these right on the first feature and every feature after it inherits the work.
| Decision | What it means | What I do | The mistake to avoid |
|---|---|---|---|
| Feature-first architecture | Top-level folders name the product, not the pattern. | features/orders/{domain,data,presentation} - layers repeat inside each feature. | Layer-first (data/, domain/ at the root). It reads well in an example and forces three-folder edits for every change. |
| Core/shared modules | One home for things actually used by several features. | core/ for errors, network client, DI, Result, theme. | Letting core/ become a junk drawer. If one feature uses it, it belongs to that feature. |
| Repository pattern | An abstract contract in domain, implemented in data. | Repository catches exceptions and returns typed Failures; caching and offline live here. | Letting the DTO be the entity. One backend rename then propagates through every widget. |
| Dependency injection | Nothing constructs its own dependencies. | get_it registered per feature at startup, injectable if the wiring grows. | A repository that news up its own HTTP client. It cannot be tested or swapped. |
| State management | Presentation-layer concern only. | Bloc or Riverpod - both call the same use cases, so the choice stays reversible. | Putting business rules inside a bloc or notifier. That is what turns a package swap into a rewrite. |
The ordering matters. Folder structure and the repository contract are hard to change later because everything depends on them. State management is easy to change later precisely because the first four decisions were made properly - which is a good reason to stop arguing about it and get the layering right first.
Key takeaways
- The dependency rule is the architecture. Everything else is detail. If domain imports Flutter, the layering is already broken.
- Keep domain pure Dart so business rules are fast to test, portable, and findable in one place.
- Structure by feature first, layer second. The folder tree should describe the product, not the pattern.
- Never let a DTO be an entity. One translation seam contains every wire-format change the backend throws at you.
- Convert exceptions to typed failures at the repository, so error handling stops leaking upward into widgets.
- Put cross-cutting concerns in the repository - offline, caching, retry - where both the layers above and below stay unaware of them.
- Adopt it deliberately. For a prototype this is overhead; for a multi-developer, multi-year app it is cheaper than the alternative.
FAQ
Does this lock me into a specific state management package?
No, and that is part of the point. Bloc, Riverpod, and ChangeNotifier all live
in the presentation layer and call the same use cases. Swapping between them
touches only that layer - domain does not know which one you chose, and the
business-rule tests do not change.
Do I really need a use case class for a one-line call?
Not always. A use case that only forwards to a repository is pure ceremony, and for trivial reads I let the presentation layer call the repository directly. What I never do is put a decision - validation, permission, pricing, eligibility - anywhere outside domain. The rule is about where judgement lives, not about maximising file count.
Where do I put dependency injection?
In core/di/, wired at startup, with one registration function per feature.
get_it is enough; injectable generates that wiring if it gets long. The
important property is that nothing below presentation constructs its own
dependencies - a repository that news up its own HTTP client cannot be tested or
swapped.
How does this interact with code generation?
freezed and json_serializable belong in the data layer with your models.
Entities can use freezed for equality and copyWith, but keep the JSON
annotations out of domain - a @JsonKey on an entity is precisely the wire
format leaking inward.
Isn't a Result type just reinventing exceptions?
It makes failure part of the return type, so the compiler reminds you to handle
it. Exceptions are invisible in a signature and easy to forget; a Result is
neither. With Dart 3 sealed classes and exhaustive switches, the compiler does
the remembering for you.
How do I test each layer?
Domain with plain test and a mocked repository. Data with a mocked HTTP client,
asserting the model-to-entity conversion and the exception-to-failure mapping.
Presentation with flutter_test and mocked use cases. The pyramid stays wide at
the bottom, because the fast tests are the ones covering the rules that actually
change.
What about very large features?
Split by sub-feature rather than growing one folder indefinitely - features/orders/checkout/, features/orders/history/. The layering repeats at
whatever granularity keeps a folder comprehensible in one screen.
Conclusion
Clean Architecture in Flutter is not about folders, and copying the folder
structure without the dependency rule gives you all the cost and none of the
benefit. Plenty of codebases have a pristine domain/ directory that imports
package:flutter/material.dart on line one.
What you are really buying is the ability to change one thing without changing everything - to swap a backend, add an offline mode, or hand a feature to a new developer without a week of archaeology first. Apply it where an app will live long enough to need that, and skip it where it will not. The judgement about when to reach for it matters more than the pattern itself.
Read more
If you are weighing this up for a real project, start with one feature rather than a rewrite. Pick the one that changes most often, layer it properly, and see whether the next change lands more easily than the last one did.
From here, the natural next questions are which state management to put in the presentation layer and what to do when the network is unreliable:
- Flutter State Management: Bloc vs Riverpod - both packages sit in the presentation layer and call the same use cases, which is what makes that choice reversible.
- Building Offline-First Flutter Applications - the sync engine belongs in the data layer, invisible to your use cases and widgets.
- How Flutter Rendering Actually Works - the layer below all of this, and where your performance budget is actually spent.