NK

Search

Search pages, projects, posts, components, and icons

All articles
Mobile19 min read

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.

flutterdartarchitecturetesting

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

TermMeaning
LayerA group of code with one job, allowed to depend only on certain other groups.
Domain layerThe innermost one. Business rules, plain Dart, no Flutter and no networking.
Data layerTalking to servers and databases, and translating what they return.
Presentation layerWidgets, state management and navigation. What the user touches.
Dependency ruleCode may depend inward, toward business logic, never outward toward frameworks.
Dependency inversionThe inner layer declares the interface it needs. The outer layer implements it.
EntityA business object with rules of its own, like an Order that knows how to price itself.
Value objectA small type that replaces a raw number or string, like Money instead of double.
Use caseOne thing a user can do, as a class with a single method.
RepositoryThe contract for getting and storing data. Declared in domain, implemented in data.
Abstract classA declaration of what methods must exist, with no implementation. The contract itself.
Data sourceThe thing that actually talks to the network or the local database.
ModelThe wire-shaped class with fromJson on it. Converted to an entity at the boundary.
Translation seamThe single place where wire format becomes domain shape, so renames stop there.
Sealed classA type with a fixed, known set of subclasses, so the compiler can check you handled all of them.
FailureA typed value describing what went wrong, returned rather than thrown.
Dependency injectionHanding a class what it needs instead of letting it construct its own.
MockA stand-in for a real dependency, used so a test can control its answers.
Code generationTools like freezed writing boilerplate Dart for you at build time.

Abbreviations

ShortFull formIn plain words
UIUser InterfaceEverything the user sees and touches
APIApplication Programming InterfaceThe set of calls your app makes to the server
HTTPHyperText Transfer ProtocolThe protocol those calls travel over
JSONJavaScript Object NotationThe text format servers usually reply in
DTOData Transfer ObjectA class shaped like the server's response, not like your business model
DIDependency InjectionHanding a class its dependencies from outside
VMVirtual MachineThe Dart runtime. Domain tests run on it directly, with no device
CRUDCreate, Read, Update, DeletePlain data plumbing with no real rules in it
RESTRepresentational State TransferThe usual style of HTTP API
CLICommand Line InterfaceA terminal tool with no graphical interface
FAQFrequently Asked QuestionsThe 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.

DecisionWhat it meansWhat I doThe mistake to avoid
Feature-first architectureTop-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 modulesOne 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 patternAn 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 injectionNothing 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 managementPresentation-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.

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: