← Articles

Clean architecture in Flutter: a practical guide

By John · 12 November 2024

Clean architecture organizes code into layers with strict dependency rules: outer layers depend on inner layers, never the reverse. In Flutter, this means UI depends on domain logic, domain logic does not depend on Flutter or Firebase.

The three layers

Presentation  →  Domain  →  Data
(Widgets,         (Entities,    (Repositories,
 BLoC/Riverpod)   Use cases,    API clients,
                  Repository    Firebase)
                  interfaces)

Domain is the heart — pure Dart, no external dependencies. It defines what the app does.

Data implements the interfaces the domain defines, using Firebase, Dio, local storage.

Presentation shows data and handles user input, delegating everything to the domain.

Folder structure

lib/
├── features/
│   └── orders/
│       ├── domain/
│       │   ├── entities/order.dart
│       │   ├── repositories/order_repository.dart  # Abstract interface
│       │   └── use_cases/
│       │       ├── get_orders.dart
│       │       └── cancel_order.dart
│       ├── data/
│       │   ├── models/order_model.dart   # Extends entity, adds fromJson/toJson
│       │   └── repositories/
│       │       └── firestore_order_repository.dart  # Implements interface
│       └── presentation/
│           ├── bloc/orders_bloc.dart
│           ├── screens/orders_screen.dart
│           └── widgets/order_card.dart

Domain layer: entities and interfaces

// lib/features/orders/domain/entities/order.dart
class Order {
  const Order({
    required this.id,
    required this.customerId,
    required this.total,
    required this.status,
    required this.createdAt,
  });

  final String id;
  final String customerId;
  final double total;
  final OrderStatus status;
  final DateTime createdAt;
}

enum OrderStatus { pending, confirmed, shipped, delivered, cancelled }
// lib/features/orders/domain/repositories/order_repository.dart
abstract interface class OrderRepository {
  Future<List<Order>> getOrders();
  Future<Order> getOrder(String id);
  Future<void> cancelOrder(String id);
  Stream<List<Order>> watchOrders();
}

Domain layer: use cases

Use cases are single-responsibility classes that orchestrate domain logic:

// lib/features/orders/domain/use_cases/cancel_order.dart
class CancelOrder {
  const CancelOrder(this._repository);
  final OrderRepository _repository;

  Future<Either<OrderError, void>> call(String orderId) async {
    try {
      final order = await _repository.getOrder(orderId);
      if (order.status != OrderStatus.pending) {
        return Left(OrderError.cannotCancelNonPendingOrder);
      }
      await _repository.cancelOrder(orderId);
      return const Right(null);
    } catch (e) {
      return Left(OrderError.networkError);
    }
  }
}

Data layer: models and implementation

// lib/features/orders/data/models/order_model.dart
class OrderModel extends Order {
  const OrderModel({
    required super.id,
    required super.customerId,
    required super.total,
    required super.status,
    required super.createdAt,
  });

  factory OrderModel.fromFirestore(DocumentSnapshot doc) {
    final data = doc.data() as Map<String, dynamic>;
    return OrderModel(
      id: doc.id,
      customerId: data['customerId'] as String,
      total: (data['total'] as num).toDouble(),
      status: OrderStatus.values.byName(data['status'] as String),
      createdAt: (data['createdAt'] as Timestamp).toDate(),
    );
  }

  Map<String, dynamic> toFirestore() => {
    'customerId': customerId,
    'total': total,
    'status': status.name,
    'createdAt': Timestamp.fromDate(createdAt),
  };
}
// lib/features/orders/data/repositories/firestore_order_repository.dart
class FirestoreOrderRepository implements OrderRepository {
  FirestoreOrderRepository(this._db);
  final FirebaseFirestore _db;

  @override
  Future<List<Order>> getOrders() async {
    final snap = await _db.collection('orders').get();
    return snap.docs.map(OrderModel.fromFirestore).toList();
  }

  @override
  Future<void> cancelOrder(String id) async {
    await _db.collection('orders').doc(id).update({'status': 'cancelled'});
  }

  @override
  Stream<List<Order>> watchOrders() {
    return _db.collection('orders').snapshots()
        .map((snap) => snap.docs.map(OrderModel.fromFirestore).toList());
  }

  // ...
}

Presentation layer: BLoC with use cases

class OrdersBloc extends Bloc<OrdersEvent, OrdersState> {
  OrdersBloc({required GetOrders getOrders, required CancelOrder cancelOrder})
      : _getOrders = getOrders,
        _cancelOrder = cancelOrder,
        super(const OrdersInitial()) {
    on<LoadOrders>(_onLoad);
    on<CancelOrderEvent>(_onCancel);
  }

  final GetOrders _getOrders;
  final CancelOrder _cancelOrder;

  Future<void> _onLoad(LoadOrders event, Emitter emit) async {
    emit(const OrdersLoading());
    final result = await _getOrders();
    result.fold(
      (error) => emit(OrdersError(error.message)),
      (orders) => emit(OrdersLoaded(orders)),
    );
  }

  Future<void> _onCancel(CancelOrderEvent event, Emitter emit) async {
    final result = await _cancelOrder(event.orderId);
    result.fold(
      (error) => emit(OrdersError(error.message)),
      (_) {/* Use case handles state update via stream or trigger reload */},
    );
  }
}

Dependency injection

@module
abstract class OrdersModule {
  @singleton
  OrderRepository orderRepository(FirebaseFirestore db) =>
      FirestoreOrderRepository(db);

  @singleton
  GetOrders getOrders(OrderRepository repo) => GetOrders(repo);

  @singleton
  CancelOrder cancelOrder(OrderRepository repo) => CancelOrder(repo);
}

Common pitfalls

Importing Flutter in the domain layer. If order.dart imports package:flutter/material.dart for Color or any other Flutter type, the domain layer now depends on Flutter. Use plain Dart types in the domain.

Use cases that are just wrappers. A use case that just calls repository.getOrders() with no other logic is pointless. Use cases earn their existence by orchestrating multiple steps, adding validation, or combining data from multiple repositories.

Fat models in the data layer. If your OrderModel has UI formatting methods (formattedPrice, statusColor), that logic belongs in the presentation layer (a view model), not the data layer.

Sign in to like, dislike, or report.

Clean architecture in Flutter: a practical guide — ANN Tech