flutter · 4 min read

A practical guide to Flutter Clean Architecture

By Vivek Patel · Updated

Clean Architecture is most useful when business rules need to survive changes to your UI or data source. Its defining feature is dependency direction, not the number of directories. A shopping app can change from an HTTP API to a local cache without rewriting checkout rules if those rules depend on an interface.

How the pieces connect

Presentation→ Domain contracts→ Data implements contracts

An illustrative folder structure

This structure explains the architecture. Exact generated paths are shown in the source walkthrough below.

lib/
  features/checkout/
    presentation/checkout_page.dart
    domain/place_order.dart
    domain/order_repository.dart
    data/api_order_repository.dart
  core/dependency_injection/container.dart

Follow one operation through the layers

A page sends a checkout action to a BLoC or notifier. The state owner invokes PlaceOrder with validated input. PlaceOrder depends on OrderRepository, a domain interface. The data implementation translates API responses into domain values. The composition root creates these objects and supplies the implementation; the domain never imports Dio or Flutter widgets.

Start with boundaries you can test

Test the order total, unavailable inventory, and retry policy using a fake repository. Widget tests should assert loading, success, and error rendering. Integration tests cover the real serialization and API contract. This separation gives failures a location and keeps business tests fast.

When the extra layers pay off

Use this approach for offline synchronization, multiple data sources, substantial business rules, or teams that work independently on features. A prototype displaying one endpoint may be better served by feature-first folders and a repository. Add a use case when it coordinates rules; a class that only forwards one call may add little value.

Avoid DTOs escaping into widgets

API models describe the remote contract and may contain nullable or transport-specific fields. Translate them at the data boundary. Keep errors meaningful: a domain failure such as out-of-stock should not be reduced to a generic HTTP exception. Do not make presentation construct concrete remote repositories.

Inside the generated starter

Trace the downloaded counter from its repository contract to a business operation, then identify where the BLoC actually writes state. This exposes the difference between generating a boundary and using it consistently.

These source files are produced from the same templates used by the generator, with the configuration below. They are starter examples; the observations identify work still needed for a real feature.

lib/domain/repositories/counter_repository.dart

abstract interface class CounterRepository {
  Future<int> load();
  Future<void> save(int value);
}

lib/features/home/domain/usecases/increment_counter.dart

import '../../../../domain/repositories/counter_repository.dart';

class IncrementCounter {
  const IncrementCounter(this.repository);
  final CounterRepository repository;
  Future<int> call() async {
    final next = await repository.load() + 1;
    await repository.save(next);
    return next;
  }
}

lib/features/home/state/counter_bloc.dart

import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/repositories/counter_repository.dart';
import 'counter_event.dart';
import 'counter_state.dart';

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc(this.repository) : super(const CounterState()) {
    on<CounterIncremented>((event, emit) async {
      final next = state.count + 1;
      await repository.save(next);
      emit(CounterState(count: next));
    });
  }
  final CounterRepository repository;
}

What to notice

The repository contract has no Flutter or Dio imports. MemoryCounterRepository stores an integer only for the lifetime of its instance. Selecting a network client does not replace that demo repository or make the counter persistent.

IncrementCounter loads, increments, and saves through the contract. The generated BLoC currently saves directly and does not invoke this use case. If your feature needs a domain rule, inject the use case into the BLoC and route the event through it; merely adding a domain directory does not enforce that rule.

The example starts from zero and omits loading and failure states. A real persisted counter needs an initial load, an explicit error path, and a policy for overlapping actions. Two asynchronous increments can otherwise calculate the same next value.

Extend it and verify the result

Change the requirement to a counter capped at ten. Put the cap in IncrementCounter, wire that operation into the BLoC, and keep the widget unaware of the rule. Use a fake repository to verify the cap before connecting storage. Decide whether exceeding the cap returns the existing value or a domain failure, and make that decision visible in the UI.

  • Starting at nine, one increment saves ten.
  • Starting at ten, another increment never saves eleven.
  • A failed save does not display a successful increment.
  • Two quick taps follow your chosen ordering policy.

These are exercises to add to your application tests, not claims that the starter already handles every case.

Try this configuration

Clean Architecture · BLoC · Dio · GetIt · GoRouter

The generator includes a working counter example with a repository contract, selected dependencies, navigation, and local setup instructions. Extend the example around your own domain before shipping.

Configure your Flutter project →

For prerequisites, exact setup commands, expected behavior, and build troubleshooting, follow the flutter download-to-first-run walkthrough. Check the validation record for the configurations actually checked.

Continue reading

Primary reference

Flutter architecture recommendations. The source walkthrough above describes AppScaffold’s implementation; platform documentation describes the broader APIs and recommendations.