App Architecture & Design Patterns

The Domain Layer: Entities & Use Cases

16 min Lesson 3 of 12

The Domain Layer: Entities & Use Cases

In Clean Architecture, the Domain layer is the innermost circle — the heart of the application. It contains pure business logic and has zero dependencies on Flutter, on any external package, or on any other layer. If you ever swap your UI framework or database driver, the Domain layer never changes. This makes it the most stable and the most testable part of your codebase.

The Domain layer is composed of two fundamental building blocks: Entities (data models that carry business rules) and Use Cases (single-responsibility classes that orchestrate those rules). Together they form a self-contained specification of what the application does, completely independent of how it does it.

Note: The Domain layer must never import anything from Flutter (package:flutter/...) or from infrastructure packages like Dio, Hive, or SharedPreferences. It may only use Dart's core SDK (dart:core, dart:async, etc.).

What Is an Entity?

An Entity is a plain Dart class that models a core business concept. It carries only the fields and validation rules that the business cares about — not database columns, not JSON keys, not widget properties. Entities are typically immutable: you use final fields and a const constructor, and you create a new instance instead of mutating an existing one.

Key characteristics of a well-designed Entity:

  • Contains only fields that have meaning to the business domain
  • May include domain-level validation (e.g., a price cannot be negative)
  • Is immutable — prefer final fields and expose a copyWith method for updates
  • Has no toJson, fromJson, or database mapping — those belong in the Data layer
  • May override ==, hashCode, and toString for convenience

Entity Example — Product

// lib/domain/entities/product.dart
// Pure Dart — no Flutter, no Dio, no Hive imports

class Product {
  final String id;
  final String name;
  final double price;
  final int stockQuantity;
  final bool isActive;

  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.stockQuantity,
    this.isActive = true,
  }) : assert(price >= 0, 'Price cannot be negative'),
       assert(stockQuantity >= 0, 'Stock cannot be negative');

  // Domain rule: a product is purchasable only if active and in stock
  bool get isPurchasable => isActive && stockQuantity > 0;

  Product copyWith({
    String? id,
    String? name,
    double? price,
    int? stockQuantity,
    bool? isActive,
  }) {
    return Product(
      id: id ?? this.id,
      name: name ?? this.name,
      price: price ?? this.price,
      stockQuantity: stockQuantity ?? this.stockQuantity,
      isActive: isActive ?? this.isActive,
    );
  }

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Product && runtimeType == other.runtimeType && id == other.id;

  @override
  int get hashCode => id.hashCode;

  @override
  String toString() => 'Product(id: $id, name: $name, price: $price)';
}
Tip: Keep domain assertions (assert) in the Entity constructor to enforce invariants at construction time. This way, an invalid Product can never exist anywhere in the application — not just in the UI.

What Is a Use Case?

A Use Case (also called an Interactor) is a single-responsibility class that encapsulates one piece of business logic. The naming convention is an action phrase: GetProductById, PlaceOrder, AuthenticateUser. Each Use Case:

  • Does exactly one thing — the Single Responsibility Principle applied strictly
  • Calls a Repository interface (an abstract class defined in the Domain layer) to fetch or persist data
  • Returns either a plain value, an Entity, or a Future/Stream of those
  • Contains no UI code and no network/database code
  • Is trivially unit-testable because every dependency can be mocked

Repository Interface & Use Case Example

// lib/domain/repositories/product_repository.dart
// Abstract contract — implementation lives in the Data layer

abstract class ProductRepository {
  Future<List<Product>> fetchAll();
  Future<Product> fetchById(String id);
  Future<void> save(Product product);
}

// lib/domain/usecases/get_purchasable_products.dart

class GetPurchasableProducts {
  final ProductRepository _repository;

  const GetPurchasableProducts(this._repository);

  // The single public method — call() makes the class callable
  Future<List<Product>> call() async {
    final all = await _repository.fetchAll();
    // Business rule: filter only purchasable products
    return all.where((p) => p.isPurchasable).toList();
  }
}

// Usage (e.g., in a ViewModel or Cubit):
// final useCase = GetPurchasableProducts(productRepository);
// final products = await useCase();  // calls call() via Dart callable syntax

Repository Interfaces Belong in the Domain Layer

A common misconception is that repositories are part of the Data layer. In Clean Architecture, the interface (the abstract class) lives in the Domain layer, while the implementation lives in the Data layer. This inversion of control (the Dependency Inversion Principle) ensures the Domain layer never depends on the Data layer — it is always the other way around.

Warning: If your Use Case imports anything from lib/data/, you have broken the dependency rule. Domain code must only reference other Domain code or pure Dart SDKs. Move the import to a concrete class in the Data layer and inject it through the repository interface.

Structuring the Domain Layer

A well-organised Domain layer follows a consistent folder structure:

  • lib/domain/entities/ — all Entity classes
  • lib/domain/repositories/ — abstract repository interfaces
  • lib/domain/usecases/ — one file per Use Case class
  • lib/domain/value_objects/ — (optional) tiny validated wrappers like Email, Money

Summary

The Domain layer is the most important layer of your application because it encodes what the business does, independent of any technology. Entities are immutable pure-Dart objects that carry business rules as computed properties and constructor assertions. Use Cases are single-method classes that call repository interfaces to execute one business operation. Neither class may import Flutter or any infrastructure package. This discipline pays for itself the first time you write a unit test that runs in milliseconds with no simulator required.

Key Takeaway: Domain = pure Dart. Entities hold business data and rules. Use Cases hold business logic. Repository interfaces define the contract. Everything else (HTTP, SQLite, Flutter widgets) lives outside the Domain layer.