Pragmatic Clean Architecture in Flutter
How I structure production Flutter apps with Clean Architecture — feature-first folders, repositories, sealed results and dependency injection — without drowning in boilerplate.

Almost every app in my portfolio is built on Clean Architecture: Range Buddy, Nightingale, Schedule Pro, Dent Shop Manager. It’s the reason I can come back to a codebase after six months, or hand it to another developer, and still ship features quickly.
But Clean Architecture has a reputation for boilerplate. Tutorials show five files and three layers to fetch a list of strings. That’s not what I do. This is the pragmatic version I use in production: the parts that pay for themselves, and the parts I skip.
Why bother?
In an earlier post on Flutter best practices, I suggested a simple folder structure — screens/, models/, services/. That works for small apps. It starts hurting when:
- Business logic ends up inside widgets and can’t be tested without pumping UI.
- Swapping an API (Firebase to a .NET backend, say) means touching dozens of screens.
- Every feature touches every folder, so changes are spread across the codebase.
Clean Architecture fixes these by enforcing one rule: dependencies point inward. UI depends on business logic; business logic depends on nothing framework-specific; data sources plug in from the outside.
Feature-first folders
I organize by feature first, then by layer inside each feature:
lib/
core/ # shared: networking, errors, theme, DI
features/
bookings/
data/ # API clients, DTOs, repository implementations
domain/ # entities, repository interfaces, (optional) use cases
presentation/ # screens, widgets, state (Bloc/Cubit/Riverpod)
auth/
data/
domain/
presentation/
Everything related to bookings lives in one place. Deleting a feature is deleting a folder. New developers can find things.
The domain layer: plain Dart
The domain layer contains your entities and the interfaces your app needs. No Flutter imports, no JSON, no HTTP:
// features/bookings/domain/booking.dart
class Booking {
const Booking({required this.id, required this.rangeName, required this.startsAt, required this.isPaid});
final String id;
final String rangeName;
final DateTime startsAt;
final bool isPaid;
bool get isUpcoming => startsAt.isAfter(DateTime.now());
}
// features/bookings/domain/booking_repository.dart
abstract interface class BookingRepository {
Future<Result<List<Booking>>> upcoming();
Future<Result<Booking>> create({required String sessionId});
}
Because it’s plain Dart, it’s trivial to unit test and it doesn’t change when you change backends.
Errors as values with sealed classes
Exceptions thrown from deep in the data layer are easy to forget to catch. Since Dart 3, I return a sealed Result type instead, and the compiler makes sure every caller handles both cases:
sealed class Result<T> {
const Result();
}
final class Ok<T> extends Result<T> {
const Ok(this.value);
final T value;
}
final class Err<T> extends Result<T> {
const Err(this.failure);
final Failure failure;
}
sealed class Failure {}
final class NetworkFailure extends Failure {}
final class UnauthorizedFailure extends Failure {}
final class ServerFailure extends Failure {
ServerFailure(this.message);
final String message;
}
Consuming it with a switch expression is concise and exhaustive:
final message = switch (result) {
Ok(value: final bookings) when bookings.isEmpty => 'No upcoming bookings',
Ok(value: final bookings) => '${bookings.length} upcoming',
Err(failure: NetworkFailure()) => 'You’re offline',
Err(failure: UnauthorizedFailure()) => 'Please sign in again',
Err(failure: ServerFailure(:final message)) => message,
};
No external package needed.
The data layer: where the mess is allowed
The data layer implements the domain interfaces. This is where JSON parsing, HTTP, caching and error translation live:
class ApiBookingRepository implements BookingRepository {
ApiBookingRepository(this._client);
final ApiClient _client;
@override
Future<Result<List<Booking>>> upcoming() async {
try {
final json = await _client.get('/bookings/upcoming');
final list = (json as List).map((e) => BookingDto.fromJson(e).toEntity()).toList();
return Ok(list);
} on SocketException {
return Err(NetworkFailure());
} on ApiException catch (e) {
return Err(e.statusCode == 401 ? UnauthorizedFailure() : ServerFailure(e.message));
}
}
}
DTOs (data transfer objects) mirror the API’s JSON; entities are what the app uses. The toEntity() mapping means an API renaming a field changes one file, not every screen.
Use cases: only when they earn their place
Strict Clean Architecture says every action gets a use case class. In practice, most use cases are one line that calls the repository — pure boilerplate.
My rule: add a use case when there’s real logic to put in it. Examples:
- Combining two repositories (create booking, then charge payment, then schedule a reminder).
- Business rules (a booking can’t be cancelled within 24 hours of the start).
- Logic reused by several screens.
Otherwise, the presentation layer talks to the repository interface directly. Nobody has ever been confused by a missing one-line wrapper.
Presentation: thin state holders
Screens render state; a Cubit, Bloc or Riverpod notifier produces it. The state holder depends on the repository interface, never on the implementation:
class UpcomingBookingsCubit extends Cubit<ScreenState<List<Booking>>> {
UpcomingBookingsCubit(this._repo) : super(Loading());
final BookingRepository _repo;
Future<void> load() async {
emit(Loading());
emit(switch (await _repo.upcoming()) {
Ok(value: final list) when list.isEmpty => Empty(),
Ok(value: final list) => Loaded(list),
Err(:final failure) => Failed(failure.toMessage()),
});
}
}
Which state management library you use matters much less than keeping this layer thin. I’ve used Provider, BLoC and Riverpod on different projects with the same overall structure.
Dependency injection: wire it once
Something has to connect BookingRepository to ApiBookingRepository. I use get_it or Riverpod providers, set up in one place at startup:
final getIt = GetIt.instance;
void configureDependencies(AppConfig config) {
getIt
..registerLazySingleton(() => ApiClient(baseUrl: config.apiUrl))
..registerLazySingleton<BookingRepository>(() => ApiBookingRepository(getIt()));
}
In tests, you register a fake instead. In a demo build, you could register an in-memory implementation. The rest of the app doesn’t know or care.
What this buys you in practice
- Testing: the domain and presentation layers are tested with plain unit tests and fake repositories — fast and reliable.
- Backend changes: when a project moved from one backend to another, only the
data/folders changed. - Onboarding: “look in
features/x” is an easy instruction to follow. - Parallel work: two developers on different features rarely touch the same files.
When not to use it
For a prototype, a hackathon project, or a two-screen utility app, this is overkill. Ship it simply, and introduce layers when the pain appears. Architecture should reduce the cost of change — if nothing is going to change, don’t pay for it.
Takeaways
- Organize by feature first, then by layer.
- Keep the domain layer pure Dart: entities and interfaces.
- Use sealed
ResultandFailuretypes so errors can’t be ignored. - Map DTOs to entities in the data layer.
- Add use cases only when they contain real logic.
- Keep state holders thin, and depend on interfaces.
Good architecture is invisible to users — but it’s what lets you keep shipping for them. (Though as I’ve written before, it won’t save you from everything.)
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).