Skip to main content

Domain Layer: Entities and Use Cases

The Domain Layer constitutes the foundational core of any application engineered under Clean Architecture. Within this layer reside the system's entities and its most stable business rules.

A fundamental characteristic in frontend engineering is that the Domain is written in 100% pure TypeScript. It does not depend on React, has no knowledge of hooks (useState), remains unaware of Next.js, and never executes direct network calls via fetch or axios.


1. The Architectural Onion in the Frontend​

The concentric structure of Clean Architecture situates the Domain at the absolute center, encircled by Use Cases and bounded externally by interface adapters:

Clean Architecture Onion in the Frontend

The Dependency Rule in Action​

  • The presentation layer (React) knows about use cases.
  • Use cases know about entities and repository contracts.
  • Entities and repository contracts have zero knowledge of anything outside the domain.

2. Modeling Domain Entities​

An Entity represents a core business concept endowed with its own identity and business invariants that must be satisfied at all times.

In our fitness feature module, the Exercise model defines required immutable properties and domain constraints:

src/features/exercises/domain/entities/exercise.ts
export type MuscleGroup = 'CHEST' | 'BACK' | 'LEGS' | 'SHOULDERS' | 'ARMS' | 'CORE';
export type Difficulty = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED';

export interface ExerciseProps {
readonly id: string;
readonly name: string;
readonly description: string;
readonly muscleGroup: MuscleGroup;
readonly difficulty: Difficulty;
readonly createdAt: Date;
}

export class Exercise {
readonly id: string;
readonly name: string;
readonly description: string;
readonly muscleGroup: MuscleGroup;
readonly difficulty: Difficulty;
readonly createdAt: Date;

constructor(props: ExerciseProps) {
this.validate(props);
this.id = props.id;
this.name = props.name;
this.description = props.description;
this.muscleGroup = props.muscleGroup;
this.difficulty = props.difficulty;
this.createdAt = props.createdAt;
}

/**
* Business invariant: An exercise cannot exist with incomplete data
*/
private validate(props: ExerciseProps): void {
if (!props.name || props.name.trim().length < 3) {
throw new Error('Exercise name must have at least 3 characters.');
}

if (!props.description || props.description.trim().length < 10) {
throw new Error('Description must have at least 10 explanatory characters.');
}
}

/**
* Business method: Determines if the exercise is suitable for beginners
*/
isBeginnerFriendly(): boolean {
return this.difficulty === 'BEGINNER';
}
}

Characteristics of a Clean Entity​

  1. Immutability: All properties are read-only (readonly). Modifying an object produces a new instance, preventing unintended side effects across the user interface.
  2. Strict Typing: We utilize union types (type MuscleGroup = 'CHEST' | ...) rather than loose string types.
  3. Invariant Validation: If incoming data violates business rules, the constructor throws an exception before an invalid object can pollute the application.

3. Repository Contracts in the Domain​

The domain requires persisting and querying data, but it must not know how or from where data is retrieved. To accomplish this, it defines an abstract contract via a TypeScript interface:

src/features/exercises/domain/repositories/exercise.repository.ts
import { Exercise } from '../entities/exercise';

export interface CreateExerciseParams {
name: string;
description: string;
muscleGroup: Exercise['muscleGroup'];
difficulty: Exercise['difficulty'];
}

export interface ExerciseRepository {
/**
* Retrieves the full list of available exercises
*/
getAll(): Promise<Exercise[]>;

/**
* Retrieves an exercise by its unique identifier
*/
getById(id: string): Promise<Exercise | null>;

/**
* Persists a new exercise and returns the persisted entity
*/
create(params: CreateExerciseParams): Promise<Exercise>;
}
Dependency Inversion Principle (DIP)

Notice that the interface is named ExerciseRepository and resides within domain/repositories/. It makes no reference to fetch, SQL, MongoDB, or Axios. The domain establishes the contract; infrastructure is tasked with fulfilling it.


4. Use Cases (Interactors)​

A Use Case encapsulates a specific user intent within the system. It adheres to the single responsibility principle: a class featuring a single public execute() method.

Use Case 1: Retrieve Exercises​

src/features/exercises/domain/usecases/get-exercises.usecase.ts
import { Exercise } from '../entities/exercise';
import { ExerciseRepository } from '../repositories/exercise.repository';

export class GetExercisesUseCase {
constructor(private readonly repository: ExerciseRepository) {}

async execute(): Promise<Exercise[]> {
const exercises = await this.repository.getAll();

// Application rule: Sort alphabetically by name
return exercises.sort((a, b) => a.name.localeCompare(b.name));
}
}

Use Case 2: Create a New Exercise​

src/features/exercises/domain/usecases/create-exercise.usecase.ts
import { Exercise } from '../entities/exercise';
import { CreateExerciseParams, ExerciseRepository } from '../repositories/exercise.repository';

export class CreateExerciseUseCase {
constructor(private readonly repository: ExerciseRepository) {}

async execute(params: CreateExerciseParams): Promise<Exercise> {
// 1. Input sanitization
const sanitizedParams: CreateExerciseParams = {
name: params.name.trim(),
description: params.description.trim(),
muscleGroup: params.muscleGroup,
difficulty: params.difficulty,
};

// 2. Business rule validation prior to delegating persistence
if (sanitizedParams.name.length < 3) {
throw new Error('Name must be at least 3 characters long.');
}

// 3. Delegation to the repository contract
return await this.repository.create(sanitizedParams);
}
}

5. The Major Benefit: Instantaneous Unit Testing​

Because use cases have zero dependencies on the DOM, React, or HTTP servers, they can be validated using unit tests that execute in milliseconds within Node.js using an in-memory test double (Mock or Stub):

src/features/exercises/domain/usecases/__tests__/create-exercise.usecase.test.ts
import { CreateExerciseUseCase } from '../create-exercise.usecase';
import { ExerciseRepository, CreateExerciseParams } from '../../repositories/exercise.repository';
import { Exercise } from '../../entities/exercise';

describe('CreateExerciseUseCase', () => {
it('should throw an error if the name has fewer than 3 characters', async () => {
// In-memory mock repository without needing Jest DOM or a backend server
const mockRepo: ExerciseRepository = {
getAll: jest.fn(),
getById: jest.fn(),
create: jest.fn(),
};

const useCase = new CreateExerciseUseCase(mockRepo);

const invalidParams: CreateExerciseParams = {
name: 'Ab',
description: 'Extensive test description',
muscleGroup: 'CORE',
difficulty: 'BEGINNER',
};

await expect(useCase.execute(invalidParams)).rejects.toThrow(
'Name must be at least 3 characters long.'
);
expect(mockRepo.create).not.toHaveBeenCalled();
});
});

6. Lesson Summary​

  1. The Domain Layer is completely agnostic of frameworks and user interfaces; it is written in pure TypeScript.
  2. Entities safeguard business invariants through immutable properties and validation routines within the constructor.
  3. Domain Repositories are strictly abstract contracts (interfaces), devoid of technical implementation details.
  4. Use Cases express discrete user intents (GetExercises, CreateExercise) and are trivial to test in isolation.
  5. In the next session, we will implement the Infrastructure Layer, constructing the concrete Repository pattern, Datasources, and defensive Mappers.

Self-Assessment Quiz​

Cargando cuestionario...