Lesson 37 of 51 advanced

Domain Layer

Entities, Use Cases & the Repository Pattern

Open interactive version (quiz + challenge)

Real-world analogy

The domain layer is like the rules of a board game. The rules say 'if you roll a six, you move six spaces.' The rules do not care if the board is made of wood or cardboard (data layer). They do not care if you are playing on a table or on the floor (presentation layer). The rules are pure logic. In Clean Architecture, the domain layer is the pure business rules -- no databases, no APIs, no Flutter -- just logic.

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

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. 1. Define Failure as an abstract Equatable class with a message, the base for all error types.
  2. 2. ServerFailure adds statusCode. CacheFailure for local storage. ValidationFailure adds fieldErrors.
  3. 3. BaseUseCase defines the contract: every use case returns Future>.
  4. 4. NoParams is used when a use case needs no input parameters.
  5. 5. TaskPriority and TaskStatus enums define valid values for task fields.
  6. 6. The Task entity has all business fields plus computed properties like isOverdue and isHighPriority.
  7. 7. isOverdue checks if the due date has passed -- business logic that belongs in the entity.
  8. 8. copyWith creates a new Task with selective field overrides while keeping id and createdAt fixed.
  9. 9. TaskRepository is abstract -- defines WHAT operations exist, not HOW they work.
  10. 10. GetTasksUseCase wraps the repository call in try-catch, returning Right on success, Left on failure.
  11. 11. CreateTaskParams bundles all input fields needed to create a new task.
  12. 12. CreateTaskUseCase validates title before any repository call -- business validation in the domain.
  13. 13. Validation failures return Left immediately, avoiding unnecessary API calls.
  14. 14. On success, a new Task entity is constructed with defaults and sent to the repository.
  15. 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?
What happens if getTaskById or deleteTask throws an exception?
Show answer
The use case has no try-catch block. If _repository.getTaskById throws (e.g., task not found, network error), the exception propagates unhandled. Both repository calls should be wrapped in try-catch returning Left(ServerFailure(e.toString())) on failure. The validation check is correct business logic, but needs error handling around all async operations.

Explain like I'm 5

Imagine you are creating the rules for a new card game. You write down: 'There are 52 cards. Each card has a suit and a number. You win if you have three of a kind.' Those rules are the domain layer. They do not say whether the cards are made of paper or plastic. They do not say whether you play at a table or on a phone app. The rules are just the pure logic of the game. If someone builds a digital version or a physical version, the rules stay the same. That is what makes the domain layer so powerful!

Fun fact

The original Clean Architecture diagram by Uncle Bob has the famous concentric circles with Entities at the center. Many developers confused 'Entities' with database entities from ORM frameworks. In Flutter Clean Architecture, entities are always business domain objects -- they have nothing to do with database tables. A database table might have 30 columns, but the entity might only expose the 10 fields the business cares about.

Hands-on challenge

Design the complete domain layer for an e-commerce 'Order' feature. Create: (1) An Order entity with id, items, status, total, createdAt, and business logic methods like canCancel and isDelivered. (2) An OrderItem value object. (3) An OrderRepository interface with CRUD operations plus getOrdersByStatus. (4) Three use cases following BaseUseCase: PlaceOrderUseCase with validation, GetOrderHistoryUseCase, and CancelOrderUseCase with validation that the order can be cancelled. Use Either for all returns.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart