HomeDocumentationc_004_flutter_enterprise
c_004_flutter_enterprise
10

Clean Architecture in Flutter: Why Your Domain Layer Should Know Nothing About Flutter

Flutter Clean Architecture: Domain, Data & Presentation Layers Explained

March 16, 2026

The Constraint That Makes Everything Else Work

Clean Architecture has one rule. Everything else follows from it.

Dependencies point inward.

Picture three concentric circles. The innermost is the domain — your business logic, your rules, your entities. The middle ring is data — your API clients, local databases, caches. The outer ring is presentation — your widgets, your screens, your state management.

Code in an outer ring can depend on code in an inner ring. Code in an inner ring can never depend on code in an outer ring. The domain knows nothing about the data layer. The domain knows nothing about Flutter. The domain doesn't know whether data comes from a REST API or a local SQLite database or a test fixture — because it doesn't need to.

That constraint sounds simple. The implications are significant.

If the domain layer has no external dependencies, it can be tested with pure Dart unit tests. No widget pump. No HTTP mocking. No test database. Just logic and assertions — fast, deterministic, reliable. And if you can test the domain in isolation, you know your business rules work regardless of what the infrastructure around them does.

This is the real value of Clean Architecture. Not the folder structure. Not the terminology. The ability to separate what your app does from how it does it — and to verify the former independently of the latter.

The Three Layers

Domain: The Heart

The domain layer is the most important and the most commonly misunderstood. It contains:

  • Entities — the core objects with identity. An Order, a User, a Product. These are not API response models. They are the domain concepts, with behaviour.
  • Value objects — immutable types that carry domain meaning. Money, EmailAddress, OrderStatus. We covered these in depth separately.
  • Aggregates — clusters of entities and value objects that enforce rules together. The Order that controls its items and calculates its own total. Full explanation here.
  • Repository interfaces — contracts that define how data is accessed, without specifying how it's implemented.
  • Use cases — single-purpose classes that represent one thing the app can do.

The test for whether something belongs in domain is simple: can you write it in plain Dart, with no imports from package:flutter, package:dio, package:hive, or any other infrastructure library? If yes, it might belong here. If no, it doesn't.

Here's a domain entity:

dart
// ✅ Pure Dart. No external dependencies.
class Order {
  final String id;
  final List<OrderItem> _items;
  final OrderStatus status;

  Order({
    required this.id,
    required List<OrderItem> items,
    required this.status,
  }) : _items = List.unmodifiable(items);

  List<OrderItem> get items => _items;

  Money get total => _items.fold(
    Money.zero,
    (sum, item) => sum.add(item.lineTotal),
  );

  Order confirm() {
    if (_items.isEmpty) {
      throw DomainException('Cannot confirm an order with no items.');
    }
    if (status != OrderStatus.pending) {
      throw DomainException('Only pending orders can be confirmed.');
    }
    return Order(id: id, items: _items, status: OrderStatus.confirmed);
  }
}

No BuildContext. No http. No JSON parsing. Just the rules that govern an order — the kind of rules a business person could read and verify.

Data: The Infrastructure

The data layer implements the repository interfaces defined in domain. It knows about APIs, databases, serialisation, and caching. The domain doesn't know any of this exists.

The key pattern here is the split between interface and implementation. In domain, you define what you need:

dart
// In domain — what the app needs, not how it's done
abstract class OrderRepository {
  Future<Either<Failure, Order>> getById(String id);
  Future<Either<Failure, List<Order>>> getAll();
  Future<Either<Failure, Unit>> save(Order order);
}

In data, you implement it:

dart
// In data — how it's actually done
class OrderRepositoryImpl implements OrderRepository {
  final OrderRemoteSource _remote;
  final OrderLocalSource _local;

  OrderRepositoryImpl(this._remote, this._local);

  @override
  Future<Either<Failure, Order>> getById(String id) async {
    try {
      // Try cache first, fall back to remote
      final cached = await _local.getOrder(id);
      if (cached != null) return Right(cached.toDomain());

      final model = await _remote.fetchOrder(id);
      await _local.saveOrder(model);
      return Right(model.toDomain());
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    } on CacheException catch (e) {
      return Left(CacheFailure(e.message));
    }
  }
}

The domain interface stays stable. The implementation can change — swap REST for GraphQL, add a cache layer, change the local database — without touching a single line of domain code. The presentation layer doesn't know any of this happened. Neither do the tests.

The data layer also contains API models (OrderModel) with their serialisation logic, separate from the domain entity (Order). These are not the same class. An API response has fields like created_at, updated_at, user_id — infrastructure concerns. The domain entity has the business representation. The conversion happens at the boundary, in the repository implementation.

Presentation: The UI

The presentation layer contains widgets, screens, and state management. It talks to use cases. It never talks to repositories directly.

A BLoC in this layer should be thin:

dart
class OrderBloc extends Bloc<OrderEvent, OrderState> {
  final ConfirmOrderUseCase _confirmOrder;
  final GetOrderUseCase _getOrder;

  OrderBloc(this._confirmOrder, this._getOrder) : super(OrderInitial()) {
    on<OrderLoadRequested>(_onLoadRequested);
    on<OrderConfirmRequested>(_onConfirmRequested);
  }

  Future<void> _onConfirmRequested(
    OrderConfirmRequested event,
    Emitter<OrderState> emit,
  ) async {
    emit(OrderLoading());
    final result = await _confirmOrder.execute(event.orderId);
    result.fold(
      (failure) => emit(OrderError(failure.message)),
      (order) => emit(OrderConfirmed(order)),
    );
  }
}

The BLoC doesn't know how confirmation works. It doesn't know about repositories, API calls, or domain rules. It dispatches to a use case and reacts to the result. If the confirmation logic changes — new validation, new side effects — the BLoC doesn't change. Only the use case does.

Use Cases: The Layer That Holds Everything Together

Use cases deserve their own section because they're the most skipped layer in Flutter projects, and skipping them is the most expensive mistake after having no architecture at all.

A use case is a single-purpose class that represents one thing the application can do. GetOrderUseCase. ConfirmOrderUseCase. AddItemToCartUseCase. One class, one action, one public method.

The reason they exist: some logic doesn't belong in the domain (it's not a business rule, it's an application behaviour) and doesn't belong in the repository (repositories are infrastructure) and doesn't belong in the BLoC (state management shouldn't orchestrate business steps). Use cases are the answer to "where does this go?"

dart
class ConfirmOrderUseCase {
  final OrderRepository _orderRepository;
  final InventoryRepository _inventoryRepository;
  final NotificationService _notificationService;

  ConfirmOrderUseCase(
    this._orderRepository,
    this._inventoryRepository,
    this._notificationService,
  );

  Future<Either<Failure, Order>> execute(String orderId) async {
    // Step 1: Load the aggregate
    final orderResult = await _orderRepository.getById(orderId);
    if (orderResult.isLeft()) return orderResult;
    final order = orderResult.getOrElse(() => throw UnexpectedError());

    // Step 2: Domain logic — the aggregate enforces its own rules
    final Order confirmedOrder;
    try {
      confirmedOrder = order.confirm();
    } on DomainException catch (e) {
      return Left(DomainFailure(e.message));
    }

    // Step 3: Orchestrate the side effects
    await _inventoryRepository.decrementStock(confirmedOrder.items);
    await _orderRepository.save(confirmedOrder);
    await _notificationService.sendConfirmation(confirmedOrder);

    return Right(confirmedOrder);
  }
}

This class does one thing. Every step is visible. Every dependency is explicit. To test it, you mock three interfaces and assert the outcome. No widgets. No HTTP. Two minutes to write the test, five seconds to run it.

When requirements change — add a loyalty points calculation, add a fraud check — you add steps to this class. The domain, the repositories, the BLoC, and the widget all stay the same.

As an app grows in complexity, use cases become the most important documentation it has. Reading the list of use cases in a feature folder tells you exactly what that feature can do, in language that maps to the business requirements.

The Folder Structure

Feature-first, then layers within each feature. Not layer-first across the whole app — that structure breaks down at any meaningful scale.

javascript
lib/
├── features/
│   └── orders/
│       ├── domain/
│       │   ├── entities/
│       │   │   └── order.dart
│       │   ├── value_objects/
│       │   │   └── order_status.dart
│       │   ├── repositories/
│       │   │   └── order_repository.dart        ← interface
│       │   └── use_cases/
│       │       ├── confirm_order_use_case.dart
│       │       └── get_order_use_case.dart
│       ├── data/
│       │   ├── models/
│       │   │   └── order_model.dart
│       │   ├── repositories/
│       │   │   └── order_repository_impl.dart   ← implementation
│       │   └── sources/
│       │       ├── order_remote_source.dart
│       │       └── order_local_source.dart
│       └── presentation/
│           ├── bloc/
│           ├── pages/
│           └── widgets/
└── core/
    ├── error/
    │   ├── failures.dart
    │   └── exceptions.dart
    └── usecase/
        └── usecase.dart                         ← base class

The core/ folder holds things that cut across features — base classes, shared failures, shared utilities. The features/ folder holds everything feature-specific, self-contained. A new developer can open features/orders/ and understand the entire orders feature without reading anything else.

The Testing Payoff

This is where the architecture pays for its upfront cost.

Domain tests run in milliseconds, import nothing outside dart:core. Test order.confirm() throws when empty. Test Money.subtract() handles negative results. These tests never break because an API changed.

Use case tests mock three interfaces with mocktail, test the orchestration. Does ConfirmOrderUseCase call decrementStock? Does it return a DomainFailure when the aggregate throws? Does it skip the notification when the order was already confirmed?

BLoC tests with bloc_test assert the full event→state contract. Emit OrderConfirmRequested, assert the sequence [OrderLoading(), OrderConfirmed(order)].

Widget tests inject a mocked BLoC and assert UI behaviour. The widget doesn't know whether the BLoC is real or fake.

When a bug surfaces in production, the layer structure tells you where to look. Domain tests pass? The rules are correct — it's an orchestration or infrastructure problem. Use case tests pass? It's a state management or UI problem. The signal is clean because the layers are clean.

The Mistakes That Are Easy to Make

Business logic in repositories. Repositories load and save data. They don't decide whether an action is allowed. A repository that checks if (order.status == 'pending') before saving is doing domain work in the infrastructure layer. Move the check to the aggregate. The repository saves whatever the domain tells it to save.

Importing Flutter in domain. The moment package:flutter appears in a domain file, you've broken the independence of the layer. Color, IconData, TextStyle — these are presentation concerns. If a domain concept needs a colour, it needs a domain representation of that colour (an enum, a string constant), not a Flutter type.

Presentation calling repositories directly. A BLoC that takes a OrderRepository instead of a ConfirmOrderUseCase is one step away from orchestrating business logic in state management. Use cases exist precisely to prevent this.

Fat use cases. If a use case is growing past twenty or thirty lines of logic, it's probably doing two things. Split it. ConfirmOrderUseCase and SendOrderNotificationUseCase are cleaner than a ConfirmOrderAndNotifyUseCase that handles both.

Anemic domain. The opposite mistake — all logic in use cases, entities as pure data bags. If Order has no behaviour, if confirmation logic lives in ConfirmOrderUseCase rather than order.confirm(), you've moved the domain logic one layer outward and lost the protection the aggregate was supposed to provide. The domain should own its own rules.

The Connection to DDD

Clean Architecture and Domain-Driven Design are complementary, not competing.

DDD tells you what the domain layer should contain and how to model it — aggregates that enforce invariants, value objects that carry meaning, bounded contexts that draw lines between parts of the system. Clean Architecture tells you where that domain layer sits and how to protect it from infrastructure concerns.

Together they answer the two questions that matter most in large Flutter applications: what are the rules, and where do they live?

If you've been following the DDD series, the domain layer in Clean Architecture is exactly where everything from that series belongs. The Order aggregate, the Money value object, the OrderRepository interface — all of them live in features/orders/domain/, independent of Flutter, independent of HTTP, testable in isolation.

The architecture enforces the discipline that DDD demands. And the DDD concepts give the architecture the vocabulary it needs to be readable.

Related Topics

flutter clean architectureflutter bloc clean architectureflutter feature-first folder structureflutter clean architecture folder structurehow to structure a flutter app

Ready to build your app?

Turn your ideas into reality with our expert mobile app development services.