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.
Glossary
Everything this post uses, defined before it is used. Skip it if the terms are already familiar, or come back when one of them trips you up.
The one sentence the rest of this post expands: put the business rules in a layer that knows nothing about Flutter or your server, and make everything else point at it.
Terms
| Term | Meaning |
|---|---|
| Layer | A group of code with one job, allowed to depend only on certain other groups. |
| Domain layer | The innermost one. Business rules, plain Dart, no Flutter and no networking. |
| Data layer | Talking to servers and databases, and translating what they return. |
| Presentation layer | Widgets, state management and navigation. What the user touches. |
| Dependency rule | Code may depend inward, toward business logic, never outward toward frameworks. |
| Dependency inversion | The inner layer declares the interface it needs. The outer layer implements it. |
| Entity | A business object with rules of its own, like an Order that knows how to price itself. |
| Value object | A small type that replaces a raw number or string, like Money instead of double. |
| Use case | One thing a user can do, as a class with a single method. |
| Repository | The contract for getting and storing data. Declared in domain, implemented in data. |
| Abstract class | A declaration of what methods must exist, with no implementation. The contract itself. |
| Data source | The thing that actually talks to the network or the local database. |
| Model | The wire-shaped class with fromJson on it. Converted to an entity at the boundary. |
| Translation seam | The single place where wire format becomes domain shape, so renames stop there. |
| Sealed class | A type with a fixed, known set of subclasses, so the compiler can check you handled all of them. |
| Failure | A typed value describing what went wrong, returned rather than thrown. |
| Dependency injection | Handing a class what it needs instead of letting it construct its own. |
| Mock | A stand-in for a real dependency, used so a test can control its answers. |
| Code generation | Tools like freezed writing boilerplate Dart for you at build time. |
Abbreviations
| Short | Full form | In plain words |
|---|---|---|
| UI | User Interface | Everything the user sees and touches |
| API | Application Programming Interface | The set of calls your app makes to the server |
| HTTP | HyperText Transfer Protocol | The protocol those calls travel over |
| JSON | JavaScript Object Notation | The text format servers usually reply in |
| DTO | Data Transfer Object | A class shaped like the server's response, not like your business model |
| DI | Dependency Injection | Handing a class its dependencies from outside |
| VM | Virtual Machine | The Dart runtime. Domain tests run on it directly, with no device |
| CRUD | Create, Read, Update, Delete | Plain data plumbing with no real rules in it |
| REST | Representational State Transfer | The usual style of HTTP API |
| CLI | Command Line Interface | A terminal tool with no graphical interface |
| FAQ | Frequently Asked Questions | The question section near the end |
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.
"Inward" is worth picturing before the layers make sense. Think of a building: the rules of the business live in the middle, and the wiring, plumbing and paint are on the outside. You can rewire a building without changing what the business does inside it. You cannot do that if the rules are welded to the wiring.
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.
Going deeper: 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.
If that sounds like a lot of classes for not much, hold the objection until the end of the section. The point is not the class. It is that "place an order" becomes a thing with a name and a home, rather than forty lines that happen to live in whichever screen needed it first.
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.
Going deeper: 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.
Going deeper: typed failures instead of thrown exceptions
Throwing an exception means the caller has to know, from documentation or memory, that this call can fail. Returning a value that is either a success or a failure means the compiler tells them instead.
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.
References
Official documentation for the topics covered here.
- Flutter: App architecture - Flutter's own architecture guidance, layers, and a recommended project shape
- Dart: Class modifiers -
sealedandabstract, the modifiers the repository contracts and failure types rely on - Dart: Patterns - exhaustive switches over sealed failures, checked at compile time
- pub.dev: get_it - the service locator used for dependency injection across the layers
- pub.dev: freezed - the generator behind the immutable models and unions
- Flutter: Testing overview - unit, widget and integration tests, and which layer each one belongs to
- Dart: Effective Dart design guide - naming and API design guidance for the boundaries between layers
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.