Flutter App Architecture Guide: Clean Architecture with BLoC & Repository Pattern (2025)

This guide explains why clean architecture matters for Flutter projects and walks through a three‑layer structure—Presentation, Domain, and Data—using BLoC for state management and a Repository pattern for data abstraction. It includes code snippets, directory layout, and dependency injection with…

When developers start a Flutter project, the temptation is to cram UI, state, and data logic into a single file or widget tree. While this works for quick demos, it quickly becomes a nightmare in production: business rules are tangled with widgets, testing is impossible, and any change to the data source forces a rewrite of the UI.

Clean Architecture offers a disciplined way to separate concerns. By enforcing a strict flow—Presentation calls Domain, Domain calls Data, and no layer reaches back up—you can swap out a REST API for GraphQL, replace BLoC with Riverpod, or change the database without touching the core business logic.

Three Layers of Flutter Clean Architecture

The architecture is split into three distinct folders:

  • Presentation – Widgets, pages, and BLoC classes that handle UI and user events.
  • Domain – Pure Dart entities, use‑case classes, and repository interfaces that model the problem space.
  • Data – Concrete implementations of repositories, data sources, DTOs, and network helpers.

Each layer only communicates downward, creating a clear contract between them. This isolation makes unit testing trivial and keeps the codebase resilient to change.

Typical Project Directory

Below is a common layout that follows the three‑layer principle:

lib/
├── core/
│   ├── error/          # Failures & Exceptions
│   ├── network/        # Dio HTTP client setup
│   └── usecases/       # Base UseCase abstract class
├── features/
│   └── orders/
│       ├── data/
│       │   ├── datasources/   # Remote API & Local Hive datasources
│       │   ├── models/         # DTO models with fromJson/toJson
│       │   └── repositories/   # Repository implementations
│       ├── domain/
│       │   ├── entities/       # Pure business objects
│       │   ├── repositories/   # Abstract repository interfaces
│       │   └── usecases/       # GetOrders, CreateOrder, etc.
│       └── presentation/
│           ├── bloc/          # OrdersBloc, OrdersState, OrdersEvent
│           ├── pages/         # OrdersPage, OrderDetailPage
│           └── widgets/       # OrderCard, OrderStatusChip

Domain Layer: Entities and Use Cases

Entities are plain Dart objects with no Flutter or JSON dependencies. For example, an Order entity might look like this:

class Order {
  final String id;
  final String customerId;
  final List<OrderItem> items;
  final OrderStatus status;
  final DateTime createdAt;

  const Order({
    required this.id,
    required this.customerId,
    required this.items,
    required this.status,
    required this.createdAt,
  });
}

Use cases encapsulate a single business operation. They are simple classes that call repository methods and return results. A GetOrders use case might be:

class GetOrders implements UseCase<List<Order>, GetOrdersParams> {
  final OrderRepository repository;

  GetOrders(this.repository);

  @override
  Future<Either<Failure, List<Order>>> call(GetOrdersParams params) {
    return repository.getOrders(customerId: params.customerId);
  }
}

Data Layer: Repository Implementation and DTOs

The repository implementation bridges the domain contracts with real data sources. It decides whether to fetch from the network or use cached data based on connectivity:

class OrderRepositoryImpl implements OrderRepository {
  final OrderRemoteDataSource remoteDataSource;
  final OrderLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  @override
  Future<Either<Failure, List<Order>>> getOrders({required String customerId}) async {
    if (await networkInfo.isConnected) {
      try {
        final remoteOrders = await remoteDataSource.getOrders(customerId);
        await localDataSource.cacheOrders(remoteOrders);
        return Right(remoteOrders.map((dto) => dto.toEntity()).toList());
      } on ServerException {
        return Left(ServerFailure());
      }
    } else {
      final cachedOrders = await localDataSource.getCachedOrders(customerId);
      return Right(cachedOrders.map((dto) => dto.toEntity()).toList());
    }
  }
}

Presentation Layer: BLoC State Management

The BLoC layer listens for UI events, triggers use cases, and emits new states. An OrdersBloc might be defined as:

class OrdersBloc extends Bloc<OrdersEvent, OrdersState> {
  final GetOrders getOrders;

  OrdersBloc({required this.getOrders}) : super(OrdersInitial()) {
    on<FetchOrdersEvent>(_onFetchOrders);
  }

  Future _onFetchOrders(
    FetchOrdersEvent event,
    Emitter<OrdersState> emit,
  ) async {
    emit(OrdersLoading());
    final result = await getOrders(GetOrdersParams(customerId: event.customerId));
    result.fold(
      (failure) => emit(OrdersError(message: failure.message)),
      (orders) => emit(OrdersLoaded(orders: orders)),
    );
  }
}

Dependency Injection with get_it

Using get_it keeps the code loosely coupled and testable. All dependencies are registered in a central container:

final sl = GetIt.instance;

Future initDependencies() async {
  // BLoC
  sl.registerFactory(() => OrdersBloc(getOrders: sl()));

  // Use Cases
  sl.registerLazySingleton(() => GetOrders(sl()));

  // Repositories
  sl.registerLazySingleton(() => OrderRepositoryImpl(
        remoteDataSource: sl(),
        localDataSource: sl(),
        networkInfo: sl(),
      ));

  // Data Sources
  sl.registerLazySingleton(() => OrderRemoteDataSourceImpl(client: sl()));
}

When to Use This Architecture

Choose Clean Architecture + BLoC when:

  • The app is expected to grow beyond 10–20 screens.
  • Multiple developers will work on the codebase.
  • You anticipate changing the data source (e.g., switching from REST to GraphQL).
  • You need robust unit tests for business logic.

For small prototypes or single‑screen apps, a simpler setState or Provider approach may suffice.

Next Steps

Start by creating the core, features, and presentation folders, then implement the domain entities and use cases. Once the data layer is wired, you can plug the BLoC into your UI and register everything with get_it. This disciplined setup saves thousands of hours of refactoring and keeps your Flutter app maintainable for years to come.

Why it matters

A clean separation of concerns lets you swap back‑ends, state managers, or UI frameworks without touching business logic, making large Flutter projects easier to test, maintain, and scale.

Key points

  • Separate UI, business logic, and data layers for testability
  • Use BLoC for predictable state management
  • Repository pattern abstracts data sources
  • Dependency injection with get_it keeps code loosely coupled
  • Clean Architecture scales with team size and app complexity

Frequently asked questions

What is the main benefit of Clean Architecture in Flutter?

It isolates business rules from UI and data layers, enabling easier testing, maintenance, and future changes.

Do I need to use BLoC if I already have a Repository?

BLoC is a state‑management pattern; you can pair it with any repository implementation. If your app is simple, you might use Provider or Riverpod instead.

Can I start a new project with this structure?

Yes, the directory layout and code snippets can be used as a template for fresh projects.

Reporting drawn from

More from World

Felo News, House 42, Bridge Colony, Kot Lakhpat, Lahore, Pakistan
+92 308 4354717 · felopronews@gmail.com