The Domain Layer: Entities & Use Cases
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.
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
finalfields and expose acopyWithmethod for updates - Has no
toJson,fromJson, or database mapping — those belong in the Data layer - May override
==,hashCode, andtoStringfor 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)';
}
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 aFuture/Streamof 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.
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 classeslib/domain/repositories/— abstract repository interfaceslib/domain/usecases/— one file per Use Case classlib/domain/value_objects/— (optional) tiny validated wrappers likeEmail,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.