Skip to content
FlutterArchitectureBLoC

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.

By Bimal Khatri·10 min read·Jun 19, 2026·Updated Jun 21, 2026

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:

LayerKnows aboutContainsDepends on
DomainNothing externalEntities, repository interfaces, use casesPure Dart only
DataDomain + the outside worldDTOs, mappers, repository implementations, data sourcesDomain
PresentationDomainBlocs/Cubits, states, widgetsDomain

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