M
Skip to content
All articles
Architecture 14 Mar 2025 · 3 min read

Scaling Flutter Apps with Clean Architecture

A practical guide to scaling Flutter apps with feature-first clean architecture, clear layer boundaries, Riverpod for state, and a testing strategy that lasts.

Mossalli Al Mamun

Senior Mobile App Engineer

Scaling Flutter Apps with Clean Architecture

When a Flutter app grows past a handful of screens, the cost of a flat lib/ folder becomes obvious: navigation logic bleeds into widgets, API calls sit next to setState, and every new feature risks breaking three others. Clean architecture, applied pragmatically, keeps a growing codebase understandable.

Feature-First, Not Layer-First

The most common mistake is organizing top-level folders by technical layer (screens/, models/, services/). It looks tidy at first but scatters a single feature across the tree. A feature-first layout keeps everything a feature needs in one place:

lib/
  core/                 # shared: theme, network, errors, di
  features/
    cart/
      data/             # datasources, DTOs, repository impls
      domain/           # entities, repository contracts, use cases
      presentation/     # widgets, screens, controllers/providers
    checkout/
    catalog/

Inside each feature, three layers enforce direction of dependency: presentation depends on domain, data depends on domain, and domain depends on nothing. The domain layer is pure Dart with no Flutter or HTTP imports, which makes it trivially testable.

The Three Layers

  • Domain — Entities and use cases. A use case is a single verb: AddToCart, PlaceOrder. Repository interfaces live here as abstract classes.
  • Data — Implements the repository contracts. Talks to REST, GraphQL, or local storage, and maps DTOs into domain entities. Nothing above this layer knows whether data came from the network or a cache.
  • Presentation — Widgets plus a controller (Riverpod Notifier) that calls use cases and exposes immutable state.

Wiring It with Riverpod

Riverpod gives compile-safe dependency injection and testable providers without a service locator. A controller depends on a use case, which depends on a repository, all resolved through providers:

class CartState {
  const CartState({this.items = const [], this.isLoading = false});
  final List<CartItem> items;
  final bool isLoading;

  CartState copyWith({List<CartItem>? items, bool? isLoading}) =>
      CartState(items: items ?? this.items, isLoading: isLoading ?? this.isLoading);
}

class CartController extends Notifier<CartState> {
  @override
  CartState build() => const CartState();

  Future<void> add(Product product) async {
    state = state.copyWith(isLoading: true);
    final addToCart = ref.read(addToCartUseCaseProvider);
    final result = await addToCart(product);
    state = result.fold(
      (failure) => state.copyWith(isLoading: false),
      (items) => CartState(items: items, isLoading: false),
    );
  }
}

final cartControllerProvider =
    NotifierProvider<CartController, CartState>(CartController.new);

Note the use of a fold over a Result/Either type. Use cases return errors as values rather than throwing, so the presentation layer handles both branches explicitly instead of scattering try/catch blocks.

Keeping Dependencies Pointing Inward

A quick rule of thumb: if you can import 'package:flutter/material.dart' anywhere in domain/, something is wrong. Enforce it with a lint. The import_lint or a custom analysis_options.yaml rule can fail CI when a forbidden import sneaks in, which matters far more than any diagram.

A Testing Strategy That Scales

Clean layering pays off most in tests. Each layer is tested in isolation:

  • Domain — Plain unit tests. No mocks needed beyond a fake repository since there is no framework.
  • Data — Mock the datasource, assert DTO-to-entity mapping and error translation.
  • Presentation — Override providers with fakes and test the controller’s state transitions.
test('add sets items on success', () async {
  final container = ProviderContainer(overrides: [
    addToCartUseCaseProvider.overrideWithValue(_FakeAddToCart()),
  ]);
  final controller = container.read(cartControllerProvider.notifier);

  await controller.add(testProduct);

  expect(container.read(cartControllerProvider).items, hasLength(1));
});

Because providers are overridable, you rarely need a widget test just to verify logic. Reserve widget and integration tests for actual UI behavior and golden files.

Practical Advice

  • Don’t create all three layers for a trivial feature. A settings toggle doesn’t need a use case.
  • Introduce a core/ module early for networking, error types, and DI so features stay thin.
  • Add the lint that guards layer boundaries on day one; retrofitting it later is painful.

Clean architecture isn’t about ceremony, it’s about keeping the direction of dependencies honest so a team can add features in parallel without stepping on each other. Start feature-first, keep the domain pure, and let Riverpod wire the rest. The payoff shows up six months in, when a “small” change stays small.

Tags: #flutter #clean-architecture #riverpod #testing #scalability
Share:

Enjoyed the read? Let's talk.

Have a project in mind? Book a free 30-minute consultation and let's turn your idea into a shippable, scalable mobile app.