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
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.dartFollow 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
- BLoC vs Riverpod: choose by how your team works
- A maintainable GetX project structure
- Structure a Flutter Riverpod application
Primary reference
Flutter architecture recommendations. The source walkthrough above describes AppScaffold’s implementation; platform documentation describes the broader APIs and recommendations.