Domain Layer
Entities, Use Cases & the Repository Pattern
Open interactive version (quiz + challenge)Real-world analogy
What is it?
The Domain Layer is the innermost layer of Clean Architecture. It contains entities (core business objects), use cases (single business operations), repository interfaces (abstract contracts for data access), failure types (structured error representations), and value objects (self-validating types). The domain layer has zero dependencies on Flutter, external packages, or other layers. It is pure Dart business logic.
Real-world relevance
In team_mvp_kit, the domain layer is the reason the team can work in parallel. The UI developer codes against repository interfaces and use cases without waiting for the API. When the team switched from REST to GraphQL for one feature, only the data layer changed. The domain layer is also where business rules live: 'Users can only have 5 active subscriptions' is a domain rule, not a UI rule or database constraint.
Key points
- Entities: The Core Models — Entities are the core business objects of your app. They contain essential fields and business logic methods. In team_mvp_kit, entities are immutable Dart classes in the domain layer.
- Entities vs DTOs — Entities represent your business concepts. DTOs represent how data looks coming from an API. They often look similar but serve different purposes. Entities are stable; DTOs change when the API changes.
- Repository Interfaces — Repository interfaces define what data operations exist without specifying how they work. The domain layer owns these interfaces. The data layer provides implementations.
- BaseUseCase Pattern from team_mvp_kit — team_mvp_kit defines a BaseUseCase interface that all use cases implement. This provides a consistent call pattern and makes use cases interchangeable and testable.
- Use Cases with Parameters — When a use case needs input, define a Params class. This keeps the use case signature clean and makes it easy to add parameters without breaking callers.
- Failure Types in Domain — The domain layer defines abstract Failure types that represent what can go wrong. The data layer maps exceptions to these failures.
- Either Pattern for Results — Many Clean Architecture implementations use the Either type from fpdart to return either a Failure or a Success value. team_mvp_kit uses this for explicit error handling without exceptions.
- Value Objects — Value objects are domain concepts defined by their value, not identity. Email, Money, and PhoneNumber are examples. They include validation in their construction.
Code example
import 'package:equatable/equatable.dart';
import 'package:fpdart/fpdart.dart';
// ---- Core: Failure types ----
abstract class Failure extends Equatable {
final String message;
const Failure(this.message);
@override
List<Object?> get props => [message];
}
class ServerFailure extends Failure {
final int? statusCode;
const ServerFailure(super.message, {this.statusCode});
@override
List<Object?> get props => [message, statusCode];
}
class CacheFailure extends Failure {
const CacheFailure(super.message);
}
class ValidationFailure extends Failure {
final Map<String, String> fieldErrors;
const ValidationFailure(super.message, {
this.fieldErrors = const {},
});
@override
List<Object?> get props => [message, fieldErrors];
}
// ---- Core: BaseUseCase ----
abstract class BaseUseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
class NoParams {
const NoParams();
}
// ---- Entity ----
enum TaskPriority { low, medium, high, urgent }
enum TaskStatus { todo, inProgress, review, done }
class Task {
final String id;
final String title;
final String description;
final TaskPriority priority;
final TaskStatus status;
final DateTime createdAt;
final DateTime? dueDate;
final String assigneeId;
const Task({
required this.id,
required this.title,
required this.description,
required this.priority,
required this.status,
required this.createdAt,
this.dueDate,
required this.assigneeId,
});
bool get isOverdue =>
dueDate != null && dueDate!.isBefore(DateTime.now());
bool get isHighPriority =>
priority == TaskPriority.high ||
priority == TaskPriority.urgent;
bool get isDone => status == TaskStatus.done;
Task copyWith({
String? title,
String? description,
TaskPriority? priority,
TaskStatus? status,
DateTime? dueDate,
String? assigneeId,
}) {
return Task(
id: id,
title: title ?? this.title,
description: description ?? this.description,
priority: priority ?? this.priority,
status: status ?? this.status,
createdAt: createdAt,
dueDate: dueDate ?? this.dueDate,
assigneeId: assigneeId ?? this.assigneeId,
);
}
}
// ---- Repository interface ----
abstract class TaskRepository {
Future<List<Task>> getTasks();
Future<Task> getTaskById(String id);
Future<void> createTask(Task task);
Future<void> updateTask(Task task);
Future<void> deleteTask(String id);
}
// ---- Use Cases ----
class GetTasksUseCase
implements BaseUseCase<List<Task>, NoParams> {
final TaskRepository _repository;
const GetTasksUseCase(this._repository);
@override
Future<Either<Failure, List<Task>>> call(
NoParams params,
) async {
try {
final tasks = await _repository.getTasks();
return Right(tasks);
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
class CreateTaskParams {
final String title;
final String description;
final TaskPriority priority;
final DateTime? dueDate;
final String assigneeId;
const CreateTaskParams({
required this.title,
required this.description,
required this.priority,
this.dueDate,
required this.assigneeId,
});
}
class CreateTaskUseCase
implements BaseUseCase<void, CreateTaskParams> {
final TaskRepository _repository;
const CreateTaskUseCase(this._repository);
@override
Future<Either<Failure, void>> call(
CreateTaskParams params,
) async {
if (params.title.trim().isEmpty) {
return const Left(
ValidationFailure('Title cannot be empty'),
);
}
if (params.title.length > 200) {
return const Left(
ValidationFailure('Title must be under 200 chars'),
);
}
try {
final task = Task(
id: '',
title: params.title.trim(),
description: params.description.trim(),
priority: params.priority,
status: TaskStatus.todo,
createdAt: DateTime.now(),
dueDate: params.dueDate,
assigneeId: params.assigneeId,
);
await _repository.createTask(task);
return const Right(null);
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
class UpdateTaskStatusParams {
final String taskId;
final TaskStatus newStatus;
const UpdateTaskStatusParams({
required this.taskId,
required this.newStatus,
});
}
class UpdateTaskStatusUseCase
implements BaseUseCase<void, UpdateTaskStatusParams> {
final TaskRepository _repository;
const UpdateTaskStatusUseCase(this._repository);
@override
Future<Either<Failure, void>> call(
UpdateTaskStatusParams params,
) async {
try {
final task = await _repository.getTaskById(
params.taskId,
);
final updated = task.copyWith(
status: params.newStatus,
);
await _repository.updateTask(updated);
return const Right(null);
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}Line-by-line walkthrough
- 1. Define Failure as an abstract Equatable class with a message, the base for all error types.
- 2. ServerFailure adds statusCode. CacheFailure for local storage. ValidationFailure adds fieldErrors.
- 3. BaseUseCase defines the contract: every use case returns Future>.
- 4. NoParams is used when a use case needs no input parameters.
- 5. TaskPriority and TaskStatus enums define valid values for task fields.
- 6. The Task entity has all business fields plus computed properties like isOverdue and isHighPriority.
- 7. isOverdue checks if the due date has passed -- business logic that belongs in the entity.
- 8. copyWith creates a new Task with selective field overrides while keeping id and createdAt fixed.
- 9. TaskRepository is abstract -- defines WHAT operations exist, not HOW they work.
- 10. GetTasksUseCase wraps the repository call in try-catch, returning Right on success, Left on failure.
- 11. CreateTaskParams bundles all input fields needed to create a new task.
- 12. CreateTaskUseCase validates title before any repository call -- business validation in the domain.
- 13. Validation failures return Left immediately, avoiding unnecessary API calls.
- 14. On success, a new Task entity is constructed with defaults and sent to the repository.
- 15. UpdateTaskStatusUseCase fetches the task, applies the change, then updates via repository.
Spot the bug
class DeleteTaskUseCase
implements BaseUseCase<void, String> {
final TaskRepository _repository;
DeleteTaskUseCase(this._repository);
@override
Future<Either<Failure, void>> call(String taskId) async {
final task = await _repository.getTaskById(taskId);
if (task.status == TaskStatus.done) {
return Left(ValidationFailure('Cannot delete completed tasks'));
}
await _repository.deleteTask(taskId);
return const Right(null);
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Dart Data Classes (dart.dev)
- fpdart - Functional Programming for Dart (pub.dev)
- Equatable Package (pub.dev)