Capa de Dominio: Entidades y Casos de Uso
La Capa de Dominio es el núcleo de cualquier aplicación construida bajo Clean Architecture. En esta capa residen las entidades y las reglas de negocio más estables del sistema.
Una característica fundamental en el frontend es que el Dominio está escrito en TypeScript 100% puro. No depende de React, no conoce ganchos (useState), no sabe qué es Next.js ni realiza llamadas directas a través de fetch o axios.
1. La Cebolla Arquitectónica en el Frontend
La estructura concéntrica de Clean Architecture sitúa al Dominio en el centro absoluto, rodeado por los Casos de Uso y finalmente por los adaptadores externos:
La Regla de Dependencia en Acción
- La capa de presentación (React) conoce a los casos de uso.
- Los casos de uso conocen a las entidades y a los contratos de repositorio.
- Las entidades y contratos de repositorio no conocen a nadie por fuera del dominio.
2. Modelado de Entidades de Dominio
Una Entidad representa un concepto central del negocio con identidad propia e invariantes que deben cumplirse en todo momento.
En nuestra feature de fitness, el modelo Exercise define los atributos inmutables requeridos y las restricciones del dominio:
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;
}
/**
* Invariante de negocio: Un ejercicio no puede existir con datos incompletos
*/
private validate(props: ExerciseProps): void {
if (!props.name || props.name.trim().length < 3) {
throw new Error('El nombre del ejercicio debe tener al menos 3 caracteres.');
}
if (!props.description || props.description.trim().length < 10) {
throw new Error('La descripción debe tener al menos 10 caracteres explicativos.');
}
}
/**
* Método de negocio: Determina si el ejercicio es adecuado para principiantes
*/
isBeginnerFriendly(): boolean {
return this.difficulty === 'BEGINNER';
}
}
Características de una Entidad Limpia
- Inmutabilidad: Todas las propiedades son de solo lectura (
readonly). Modificar un objeto genera una nueva instancia, evitando efectos secundarios imprevistos en la interfaz de usuario. - Tipado Estricto: Hacemos uso de tipos de unión (
type MuscleGroup = 'CHEST' | ...) en lugar destringabiertos. - Validación de Invariantes: Si los datos no cumplen las reglas del negocio, el constructor arroja una excepción antes de que el objeto inválido contamine la aplicación.
3. Contratos de Repositorio en el Dominio
El dominio necesita persistir y consultar datos, pero no debe saber cómo ni de dónde se obtienen. Para lograrlo, define un contrato abstracto mediante una interface de TypeScript:
import { Exercise } from '../entities/exercise';
export interface CreateExerciseParams {
name: string;
description: string;
muscleGroup: Exercise['muscleGroup'];
difficulty: Exercise['difficulty'];
}
export interface ExerciseRepository {
/**
* Obtiene la lista completa de ejercicios disponibles
*/
getAll(): Promise<Exercise[]>;
/**
* Obtiene un ejercicio por su identificador único
*/
getById(id: string): Promise<Exercise | null>;
/**
* Guarda un nuevo ejercicio y retorna la entidad persistida
*/
create(params: CreateExerciseParams): Promise<Exercise>;
}
Fíjate en que la interfaz se llama ExerciseRepository y está ubicada dentro de la carpeta domain/repositories/. No tiene mención a fetch, SQL, MongoDB ni Axios. El dominio define el contrato; la infraestructura se encargará de cumplirlo.
4. Casos de Uso (Interactors)
Un Caso de Uso encapsula una intención específica del usuario en el sistema. Sigue el principio de responsabilidad única: una clase con un único método público execute().
Caso de Uso 1: Obtener Ejercicios
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();
// Regla de aplicación: ordenar alfabéticamente por nombre
return exercises.sort((a, b) => a.name.localeCompare(b.name));
}
}
Caso de Uso 2: Crear un Nuevo Ejercicio
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. Sanitización de entradas
const sanitizedParams: CreateExerciseParams = {
name: params.name.trim(),
description: params.description.trim(),
muscleGroup: params.muscleGroup,
difficulty: params.difficulty,
};
// 2. Validación de reglas antes de delegar a la persistencia
if (sanitizedParams.name.length < 3) {
throw new Error('El nombre debe tener al menos 3 caracteres.');
}
// 3. Delegación al repositorio
return await this.repository.create(sanitizedParams);
}
}
5. El Gran Beneficio: Pruebas Unitarias Instantáneas
Al no depender del DOM, de React ni de servidores HTTP, los casos de uso se pueden verificar con pruebas unitarias que corren en milisegundos en Node.js mediante un repositorio falso (Mock o Stub):
import { CreateExerciseUseCase } from '../create-exercise.usecase';
import { ExerciseRepository, CreateExerciseParams } from '../../repositories/exercise.repository';
import { Exercise } from '../../entities/exercise';
describe('CreateExerciseUseCase', () => {
it('debe arrojar error si el nombre tiene menos de 3 caracteres', async () => {
// Repositorio falso en memoria sin necesidad de levantar Jest DOM ni backend
const mockRepo: ExerciseRepository = {
getAll: jest.fn(),
getById: jest.fn(),
create: jest.fn(),
};
const useCase = new CreateExerciseUseCase(mockRepo);
const invalidParams: CreateExerciseParams = {
name: 'Ab',
description: 'Descripción de prueba extensa',
muscleGroup: 'CORE',
difficulty: 'BEGINNER',
};
await expect(useCase.execute(invalidParams)).rejects.toThrow(
'El nombre debe tener al menos 3 caracteres.'
);
expect(mockRepo.create).not.toHaveBeenCalled();
});
});
6. Resumen de la Lección
- La Capa de Dominio es agnóstica de frameworks e interfaces de usuario; se escribe en TypeScript puro.
- Las Entidades salvaguardan las invariantes del negocio mediante atributos inmutables y validaciones en el constructor.
- Los Repositorios en Dominio son únicamente contratos abstractos (
interfaces), sin implementación técnica. - Los Casos de Uso expresan intenciones directas del usuario (
GetExercises,CreateExercise) y son triviales de testear de forma aislada. - En la próxima sesión aprenderemos a implementar la Capa de Infraestructura, construyendo el patrón Repositorio concreto, los Datasources y los Mappers defensivos.