Clean Architecture in Flutter with BLoC: A Practical Guide
A hands-on guide to splitting a Flutter app into domain, data, and presentation layers with BLoC — a full feature: entity, use case, repository with DTO mapping, and a Cubit.
Clean architecture in Flutter is the single biggest reason the production apps I ship stay maintainable after a year of feature churn. Over 4+ years building iOS and Android apps, I've watched "just put the logic in the widget" turn setState spaghetti into a codebase nobody wants to touch. This guide walks through how I actually split a Flutter app into domain, data, and presentation layers with BLoC — using one concrete feature so you can copy the structure into your own project today.
I'll build a small "Todos" feature end to end: an entity, a use case, a repository with a DTO mapper, and a Cubit that drives the UI. The point isn't the todo list — it's the boundaries between layers and why each one earns its keep.
Why clean architecture in Flutter pays off
The core idea is the dependency rule: source-code dependencies point inward. The UI knows about the domain; the domain knows about nothing. Your business rules never import Flutter, Firebase, Dio, or Supabase.
That inversion buys three things I care about on every project:
- Testability. Domain logic runs in plain Dart unit tests — no widget pump, no emulator, no network.
- Swappable infrastructure. Move from REST to GraphQL, or Firestore to a local SQLite cache, by rewriting one data-layer class. The domain and UI don't change.
- Parallel work. Once the domain contract exists, one person builds the API client while another builds the screen against a fake.
Here's the layer breakdown I use, and what's allowed to live in each:
| Layer | Knows about | Contains | Depends on |
|---|---|---|---|
| Domain | Nothing external | Entities, repository interfaces, use cases | Pure Dart only |
| Data | Domain + the outside world | DTOs, mappers, repository implementations, data sources | Domain |
| Presentation | Domain | Blocs/Cubits, states, widgets | Domain |
Notice the data and presentation layers both depend on domain, and domain depends on neither. That's the whole game.
Folder structure that scales
I organise by feature first, then by layer. A flat models/, services/, screens/ split looks tidy on day one and becomes a scavenger hunt by feature five. Feature-first keeps everything you touch for one change in one place.
lib/
features/
todos/
domain/
entities/todo.dart
repositories/todo_repository.dart # abstract
usecases/get_todos.dart
data/
models/todo_dto.dart # JSON -> DTO + mapper
datasources/todo_remote_data_source.dart
repositories/todo_repository_impl.dart
presentation/
cubit/todos_cubit.dart
cubit/todos_state.dart
pages/todos_page.dart
core/
error/failures.dart
usecases/usecase.dart
The core/ directory holds cross-feature primitives — failure types, a base UseCase contract, shared extensions. Everything feature-specific stays inside its feature folder.
The domain layer: entities and use cases
The domain is pure Dart. No package:flutter, no JSON, no annotations. Start with the entity — the shape your app reasons about, not the shape the API returns.
// domain/entities/todo.dart
import 'package:equatable/equatable.dart';
class Todo extends Equatable {
const Todo({
required this.id,
required this.title,
required this.isCompleted,
});
final String id;
final String title;
final bool isCompleted;
@override
List<Object?> get props => [id, title, isCompleted];
}
I extend Equatable so two todos with the same fields compare equal — that makes Cubit state comparisons and tests painless.
Next, the repository interface. This is the contract the domain demands from the outside world. It lives in domain, but it's implemented in data. That's the dependency inversion that keeps Firebase or Dio out of your business rules.
// domain/repositories/todo_repository.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/todo.dart';
abstract interface class TodoRepository {
Future<Either<Failure, List<Todo>>> getTodos();
Future<Either<Failure, Todo>> toggle(String id);
}
I return Either<Failure, T> from dartz instead of throwing across layers. Failures become values you must handle, not exceptions that silently bubble into the UI. Failure is a sealed type in core/:
// core/error/failures.dart
sealed class Failure {
const Failure(this.message);
final String message;
}
class ServerFailure extends Failure {
const ServerFailure([super.message = 'Something went wrong']);
}
class NetworkFailure extends Failure {
const NetworkFailure([super.message = 'No internet connection']);
}
Now the use case. A use case is one application action with a single public method. It reads almost like a sentence: get todos. This is where orchestration lives — call a repository, maybe combine two, apply a business rule — without the Cubit ever knowing how data is fetched.
// core/usecases/usecase.dart
import 'package:dartz/dartz.dart';
import '../error/failures.dart';
abstract interface class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
class NoParams {
const NoParams();
}
// domain/usecases/get_todos.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/todo.dart';
import '../repositories/todo_repository.dart';
class GetTodos implements UseCase<List<Todo>, NoParams> {
const GetTodos(this._repository);
final TodoRepository _repository;
@override
Future<Either<Failure, List<Todo>>> call(NoParams params) {
return _repository.getTodos();
}
}
People ask whether use cases are overkill when they just forward to a repository. Early on, many do — and that's a fair criticism. But the seam is what matters: the day "get todos" needs to filter archived items, merge a local cache, or log analytics, you change one file and every caller benefits. That discipline is what makes the domain/data/presentation split scale instead of rot.
The data layer: DTOs, mappers, and the repository
The data layer is where the messy outside world lives — and where it stays. The key move is a DTO (data transfer object) that mirrors the JSON exactly, plus a mapper that converts it to a clean domain entity. Never let raw JSON shapes leak into your domain.
// data/models/todo_dto.dart
import '../../domain/entities/todo.dart';
class TodoDto {
const TodoDto({
required this.id,
required this.title,
required this.completed,
});
final String id;
final String title;
final bool completed;
factory TodoDto.fromJson(Map<String, dynamic> json) {
return TodoDto(
id: json['id'] as String,
title: json['title'] as String,
completed: json['is_done'] as bool? ?? false,
);
}
Todo toEntity() => Todo(
id: id,
title: title,
isCompleted: completed,
);
}
See the value here: the API uses is_done, my domain uses isCompleted, and the API might send null. All of that ugliness is quarantined in one mapper. If the backend renames a field tomorrow, exactly one file changes.
The data source owns the transport — Dio, http, Firestore — and speaks DTOs:
// data/datasources/todo_remote_data_source.dart
import 'package:dio/dio.dart';
import '../models/todo_dto.dart';
abstract interface class TodoRemoteDataSource {
Future<List<TodoDto>> fetchTodos();
}
class TodoRemoteDataSourceImpl implements TodoRemoteDataSource {
const TodoRemoteDataSourceImpl(this._dio);
final Dio _dio;
@override
Future<List<TodoDto>> fetchTodos() async {
final res = await _dio.get<List<dynamic>>('/todos');
final data = res.data ?? const <dynamic>[];
return data
.map((e) => TodoDto.fromJson(e as Map<String, dynamic>))
.toList();
}
}
Finally, the repository implementation stitches it together: call the data source, map DTOs to entities, and convert exceptions into Failure values. This is the only class that implements the domain's TodoRepository.
// data/repositories/todo_repository_impl.dart
import 'package:dartz/dartz.dart';
import 'package:dio/dio.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/todo.dart';
import '../../domain/repositories/todo_repository.dart';
import '../datasources/todo_remote_data_source.dart';
class TodoRepositoryImpl implements TodoRepository {
const TodoRepositoryImpl(this._remote);
final TodoRemoteDataSource _remote;
@override
Future<Either<Failure, List<Todo>>> getTodos() async {
try {
final dtos = await _remote.fetchTodos();
return Right(dtos.map((d) => d.toEntity()).toList());
} on DioException {
return const Left(NetworkFailure());
} catch (_) {
return const Left(ServerFailure());
}
}
@override
Future<Either<Failure, Todo>> toggle(String id) async {
// Omitted for brevity — same try / map / Either shape as getTodos.
throw UnimplementedError();
}
}
This is the repository pattern doing its real job: the domain asked for List<Todo> or a typed failure, and got exactly that — with every Dio detail absorbed here.
The presentation layer: a Cubit driving the UI
For most screens I reach for Cubit over the full event-based Bloc. Cubit is simpler — you call methods directly and emit states — and it covers the majority of UI logic. I switch to a Bloc when I genuinely need an event stream: debouncing search input, transforming concurrent events, or keeping an audit trail of what triggered each transition.
I model state as a sealed class, so the widget layer is forced to handle every case and the compiler catches the one I forgot.
// presentation/cubit/todos_state.dart
import 'package:equatable/equatable.dart';
import '../../domain/entities/todo.dart';
sealed class TodosState extends Equatable {
const TodosState();
@override
List<Object?> get props => [];
}
class TodosInitial extends TodosState {
const TodosInitial();
}
class TodosLoading extends TodosState {
const TodosLoading();
}
class TodosLoaded extends TodosState {
const TodosLoaded(this.todos);
final List<Todo> todos;
@override
List<Object?> get props => [todos];
}
class TodosError extends TodosState {
const TodosError(this.message);
final String message;
@override
List<Object?> get props => [message];
}
The Cubit depends only on the use case — never on a repository or Dio directly:
// presentation/cubit/todos_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/usecases/usecase.dart';
import '../../domain/usecases/get_todos.dart';
import 'todos_state.dart';
class TodosCubit extends Cubit<TodosState> {
TodosCubit(this._getTodos) : super(const TodosInitial());
final GetTodos _getTodos;
Future<void> load() async {
emit(const TodosLoading());
final result = await _getTodos(const NoParams());
result.fold(
(failure) => emit(TodosError(failure.message)),
(todos) => emit(TodosLoaded(todos)),
);
}
}
And the widget switches over the sealed state — exhaustively, with no default branch needed:
// presentation/pages/todos_page.dart (excerpt)
BlocBuilder<TodosCubit, TodosState>(
builder: (context, state) => switch (state) {
TodosInitial() || TodosLoading() =>
const Center(child: CircularProgressIndicator()),
TodosError(:final message) => Center(child: Text(message)),
TodosLoaded(:final todos) => ListView.builder(
itemCount: todos.length,
itemBuilder: (_, i) => CheckboxListTile(
value: todos[i].isCompleted,
title: Text(todos[i].title),
onChanged: (_) {},
),
),
},
)
Wiring it all together happens once, at the composition root, with a DI container like get_it (here sl is the shared GetIt service-locator instance). Each layer receives its inward dependency through the constructor — that's what makes everything mockable.
final sl = GetIt.instance;
sl.registerLazySingleton<TodoRemoteDataSource>(
() => TodoRemoteDataSourceImpl(sl()));
sl.registerLazySingleton<TodoRepository>(
() => TodoRepositoryImpl(sl()));
sl.registerLazySingleton(() => GetTodos(sl()));
sl.registerFactory(() => TodosCubit(sl()));
How clean architecture in Flutter wins on testing
Here's the payoff. Because every dependency is an interface injected through a constructor, I can test each layer in isolation with a fake — no Flutter, no network:
// test/todos_cubit_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:dartz/dartz.dart';
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';
class MockGetTodos extends Mock implements GetTodos {}
void main() {
late MockGetTodos getTodos;
setUp(() => getTodos = MockGetTodos());
setUpAll(() => registerFallbackValue(const NoParams()));
blocTest<TodosCubit, TodosState>(
'emits [Loading, Loaded] when the use case succeeds',
build: () {
when(() => getTodos(any())).thenAnswer(
(_) async => const Right([
Todo(id: '1', title: 'Ship it', isCompleted: false),
]),
);
return TodosCubit(getTodos);
},
act: (cubit) => cubit.load(),
expect: () => [
const TodosLoading(),
const TodosLoaded([Todo(id: '1', title: 'Ship it', isCompleted: false)]),
],
);
}
This test runs in milliseconds and never touches a real API. I can write the failure-path test the same way by returning Left(NetworkFailure()). Testability stops being a chore because the architecture made the dependencies fake-able by default. The same property is what lets these projects grow: a new feature is a new folder with the same four shapes, not a new way of doing things.
Wrapping up
Clean architecture in Flutter isn't about ceremony — it's about putting boundaries where change happens: entities and use cases that never import infrastructure, a data layer that quarantines every API quirk behind a mapper, and a Cubit that only knows the domain. Start with one feature folder, resist letting Dio or JSON leak inward, and the structure compounds: faster tests, swappable backends, and parallel teamwork.
If you're untangling a Flutter codebase or want a second pair of eyes on your BLoC layering, get in touch — and if you're staffing a project that needs this done right from the start, you can hire me.
More writing
Keep reading
Jun 10, 2026
Offline-First Flutter: Syncing Local and Remote Data Reliably
A practical engineering guide to offline-first Flutter: a local source of truth, a durable outbox sync queue, conflict resolution (last-write-wins vs. merge), and real failure handling.
Jan 20, 2025
Automating Flutter Releases with GitHub Actions
Turning the stressful release-day ritual into a calm, repeatable pipeline that ships signed builds to both stores.
Nov 3, 2024
Building Offline-First Apps with Hive and BLoC
A pattern for caching writes locally and reconciling with the server so the app stays usable with no connection.