Tutorial 10 min read

Flutter Clean Architecture: A Practical Guide

Mahmoud Hamdy
February 3, 2026

Flutter is a fantastic framework for shipping beautiful, fast apps across platforms. But as your app grows beyond a few screens, ad-hoc code organization breaks down fast. Clean architecture — originally described by Robert C. Martin — gives you a disciplined separation of concerns that makes large Flutter codebases maintainable, testable, and extensible. This guide walks through a real-world Flutter project structure using clean architecture, from folder layout to dependency injection to per-layer testing strategy.

The Three Layers

Clean architecture divides your app into three concentric layers. The dependency rule is simple and strict: outer layers depend on inner layers, never the reverse.

  • Domain layer — The core. Contains Entities, Use Cases, and Repository interfaces. Zero Flutter dependencies. Pure Dart.
  • Data layer — Implements Repository interfaces. Contains Models (JSON serialization), Data Sources (remote API, local DB), and Repository implementations.
  • Presentation layer — Flutter UI. Contains Pages, Widgets, and state management (Bloc/Cubit). Calls Use Cases, never touches the data layer directly.

Folder Structure

I organize by feature first, then by layer inside each feature. This scales well because all code related to a feature lives together.

lib/
├── core/
│   ├── error/
│   │   ├── failures.dart        # Sealed failure classes
│   │   └── exceptions.dart
│   ├── network/
│   │   └── network_info.dart    # Connectivity check
│   ├── usecases/
│   │   └── usecase.dart         # Base UseCase interface
│   └── di/
│       └── injection.dart       # GetIt service locator setup
│
├── features/
│   └── products/
│       ├── domain/
│       │   ├── entities/
│       │   │   └── product.dart
│       │   ├── repositories/
│       │   │   └── product_repository.dart  # abstract
│       │   └── usecases/
│       │       ├── get_products.dart
│       │       └── get_product_by_id.dart
│       │
│       ├── data/
│       │   ├── models/
│       │   │   └── product_model.dart       # extends Product entity
│       │   ├── datasources/
│       │   │   ├── product_remote_source.dart
│       │   │   └── product_local_source.dart
│       │   └── repositories/
│       │       └── product_repository_impl.dart
│       │
│       └── presentation/
│           ├── bloc/
│           │   ├── product_bloc.dart
│           │   ├── product_event.dart
│           │   └── product_state.dart
│           ├── pages/
│           │   └── products_page.dart
│           └── widgets/
│               └── product_card.dart
│
└── main.dart

Domain Layer

The domain layer has no external dependencies — no Flutter, no Dio, no database packages. Every entity is a pure Dart class. I use the dartz package for the Either type, which forces explicit error handling at every call site.

// features/products/domain/entities/product.dart
import 'package:equatable/equatable.dart';

class Product extends Equatable {
  final String id;
  final String name;
  final double price;
  final String imageUrl;
  final String categoryId;

  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.imageUrl,
    required this.categoryId,
  });

  @override
  List<Object?> get props => [id, name, price, imageUrl, categoryId];
}
// features/products/domain/repositories/product_repository.dart
import 'package:dartz/dartz.dart';
import '../entities/product.dart';
import '../../../../core/error/failures.dart';

abstract class ProductRepository {
  Future<Either<Failure, List<Product>>> getProducts();
  Future<Either<Failure, Product>> getProductById(String id);
}
// features/products/domain/usecases/get_products.dart
import 'package:dartz/dartz.dart';
import '../entities/product.dart';
import '../repositories/product_repository.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';

class GetProducts implements UseCase<List<Product>, NoParams> {
  final ProductRepository repository;
  const GetProducts(this.repository);

  @override
  Future<Either<Failure, List<Product>>> call(NoParams params) {
    return repository.getProducts();
  }
}

Data Layer

Models extend entities and add JSON serialization. The repository implementation handles the offline-first pattern: try remote, catch exception, fall back to cache.

// features/products/data/models/product_model.dart
import '../../domain/entities/product.dart';

class ProductModel extends Product {
  const ProductModel({
    required super.id,
    required super.name,
    required super.price,
    required super.imageUrl,
    required super.categoryId,
  });

  factory ProductModel.fromJson(Map<String, dynamic> json) {
    return ProductModel(
      id: json['id'] as String,
      name: json['name'] as String,
      price: (json['price'] as num).toDouble(),
      imageUrl: json['image_url'] as String,
      categoryId: json['category_id'] as String,
    );
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'price': price,
    'image_url': imageUrl,
    'category_id': categoryId,
  };
}
// features/products/data/repositories/product_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/error/exceptions.dart';
import '../datasources/product_remote_source.dart';
import '../datasources/product_local_source.dart';

class ProductRepositoryImpl implements ProductRepository {
  final ProductRemoteSource remote;
  final ProductLocalSource local;

  const ProductRepositoryImpl({ required this.remote, required this.local });

  @override
  Future<Either<Failure, List<Product>>> getProducts() async {
    try {
      final products = await remote.getProducts();
      await local.cacheProducts(products);
      return Right(products);
    } on ServerException catch (e) {
      try {
        final cached = await local.getCachedProducts();
        return Right(cached);
      } on CacheException {
        return Left(ServerFailure(e.message));
      }
    }
  }

  @override
  Future<Either<Failure, Product>> getProductById(String id) async {
    try {
      final product = await remote.getProductById(id);
      return Right(product);
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    }
  }
}

Presentation Layer with Bloc

The Cubit (simplified Bloc) receives the Use Case via constructor injection. It never imports from the data layer. State is sealed — either loading, loaded, or error — which makes UI code predictable.

// features/products/presentation/bloc/product_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_products.dart';
import '../../../../core/usecases/usecase.dart';
import 'product_state.dart';

class ProductCubit extends Cubit<ProductState> {
  final GetProducts getProducts;
  ProductCubit({ required this.getProducts }) : super(ProductInitial());

  Future<void> loadProducts() async {
    emit(ProductLoading());
    final result = await getProducts(const NoParams());
    result.fold(
      (failure) => emit(ProductError(failure.message)),
      (products) => emit(ProductLoaded(products)),
    );
  }
}
// features/products/presentation/pages/products_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/product_cubit.dart';
import '../bloc/product_state.dart';
import '../widgets/product_card.dart';

class ProductsPage extends StatefulWidget {
  const ProductsPage({super.key});
  @override
  State<ProductsPage> createState() => _ProductsPageState();
}

class _ProductsPageState extends State<ProductsPage> {
  @override
  void initState() {
    super.initState();
    context.read<ProductCubit>().loadProducts();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: BlocBuilder<ProductCubit, ProductState>(
        builder: (context, state) {
          if (state is ProductLoading) return const Center(child: CircularProgressIndicator());
          if (state is ProductError) return Center(child: Text(state.message));
          if (state is ProductLoaded) {
            return GridView.builder(
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
              itemCount: state.products.length,
              itemBuilder: (ctx, i) => ProductCard(product: state.products[i]),
            );
          }
          return const SizedBox.shrink();
        },
      ),
    );
  }
}

Dependency Injection with GetIt

I use get_it as a service locator. All wiring happens in one place so the rest of the codebase never instantiates dependencies directly.

// core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../../features/products/data/datasources/product_remote_source.dart';
import '../../features/products/data/datasources/product_local_source.dart';
import '../../features/products/data/repositories/product_repository_impl.dart';
import '../../features/products/domain/repositories/product_repository.dart';
import '../../features/products/domain/usecases/get_products.dart';
import '../../features/products/presentation/bloc/product_cubit.dart';

final sl = GetIt.instance;

Future<void> initDependencies() async {
  // External
  sl.registerLazySingleton(() => Dio());

  // Data sources
  sl.registerLazySingleton<ProductRemoteSource>(
    () => ProductRemoteSourceImpl(dio: sl()),
  );
  sl.registerLazySingleton<ProductLocalSource>(
    () => ProductLocalSourceImpl(),
  );

  // Repository
  sl.registerLazySingleton<ProductRepository>(
    () => ProductRepositoryImpl(remote: sl(), local: sl()),
  );

  // Use cases
  sl.registerLazySingleton(() => GetProducts(sl()));

  // Bloc / Cubit
  sl.registerFactory(() => ProductCubit(getProducts: sl()));
}

Testing per Layer

Clean architecture's biggest benefit is testability. Each layer can be tested in isolation.

  • Domain: Unit test use cases by mocking the repository interface. No Flutter, no IO.
  • Data: Test repository implementations by mocking remote/local sources. Test models with fixture JSON.
  • Presentation: Use bloc_test to test Cubit state transitions. Use flutter_test with BlocProvider for widget tests.
// test/features/products/domain/get_products_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:dartz/dartz.dart';

class MockProductRepository extends Mock implements ProductRepository {}

void main() {
  late GetProducts usecase;
  late MockProductRepository mockRepo;

  setUp(() {
    mockRepo = MockProductRepository();
    usecase = GetProducts(mockRepo);
  });

  test('should return list of products from repository', () async {
    final products = [const Product(id:'1', name:'Keffiyeh', price:99, imageUrl:'', categoryId:'c1')];
    when(mockRepo.getProducts()).thenAnswer((_) async => Right(products));

    final result = await usecase(const NoParams());

    expect(result, Right(products));
    verify(mockRepo.getProducts());
    verifyNoMoreInteractions(mockRepo);
  });
}

Common Mistakes to Avoid

  • Importing flutter/material.dart in the domain layer — your entities should be pure Dart.
  • Putting business logic in the Cubit — use cases exist for a reason.
  • Using a single giant repository instead of one per feature.
  • Skipping Equatable on entities — Bloc comparisons will break without it.
  • Registering everything as registerSingleton in GetIt — Cubits should be registerFactory so they get a fresh instance per page.

Final Thoughts

Clean architecture feels like extra ceremony on a small project, but it pays for itself the moment a second developer joins or you need to swap a data source. The strict dependency rule — domain knows nothing about data or presentation — is the key insight. Once it clicks, you will never go back to dumping everything in a single file.

The complete boilerplate for this architecture is available in my GitHub repositories if you want to use it as a starting point for your next Flutter project.