Arquitectura
¿Realmente estás usando Clean Architecture?
Cómo los transaction scripts disfrazados pueden estar saboteando tu dominio.
Cristiano Rego Barros
8 min de lectura
Publicado originalmente en Medium
¿Alguna vez oíste hablar de transaction script y domain model? Seguramente sí, y muy probablemente vinculados a temas como DDD o Clean Architecture. Pero ¿sabes en qué se diferencian? ¿Cuál es el impacto de usar cada patrón? En este breve artículo mostraré algunos ejemplos y, en especial, una migración del script transaccional al modelo de dominio.
Para quienes se inician en la arquitectura limpia, vale la pena entender un poco mejor las implicaciones de cada modelo. Muchos desarrolladores, al acercarse a estos temas, pueden pensar que están creando una arquitectura limpia por poner la regla de negocio en casos de uso, tener clases llamadas entidades y usar repositorios para comunicarse con la base de datos, cuando en realidad la arquitectura limpia va un poco más allá.
Para Fowler, el transaction script trata cada lógica de comunicación entre cliente y servidor como un único procedimiento: cada transacción tiene su script transaccional, y las subactividades comunes pueden dividirse en procedimientos más pequeños. A partir de esta descripción, podemos imaginar una aplicación con sus comportamientos expuestos, cada uno con su propio procedimiento, escrito de forma procedural.
Entonces surge la pregunta: «pero en mi aplicación la regla de negocio está encapsulada en casos de uso, así que no tengo código procedural en mi proyecto». ¿Seguro?
El punto de partida: una flota de vehículos
Para facilitar la comprensión, imaginemos un sistema de gestión de flotas de vehículos. Para empezar, el cliente pide poder registrar vehículos nuevos y actualizarlos. El desarrollador comienza creando una clase que representa la entidad auto.
class Car {
public id: string;
public engine: string;
public color: string;
public ignitionOn: boolean;
public documentStatus: CarDocumentStatus;
public chassi: string;
constructor(
id: string,
engine: string,
color: string,
ignitionOn: boolean,
documentStatus: CarDocumentStatus,
chassi: string,
) {
this.id = id;
this.engine = engine;
this.color = color;
this.ignitionOn = ignitionOn;
this.documentStatus = documentStatus;
this.chassi = chassi;
}
}
Ahora que el desarrollador tiene su «entidad», pasa a crear el caso de uso de registrar vehículos nuevos.
interface ICarRepository {
save(car: Car): Promise<void>;
findById(carId: string): Promise<Car>;
}
type CreateNewCarInput = {
engine: string;
color: string;
ignitionOn: boolean;
documentStatus: CarDocumentStatus;
chassi: string;
};
class CreateNewCarUseCase {
constructor(private readonly repository: ICarRepository) {}
async execute(input: CreateNewCarInput): Promise<{ carId: string }> {
if (!input.engine || !input.chassi || !input.color || !input.chassi) {
throw new ValidationError('No se envió algún atributo obligatorio');
}
if (!/^\d[A-HJ-NP-PR-Z]{4}\d{2}[A-HJ-NP-PR-Z]{4}\d{6}$/.test(input.chassi)) {
throw new ValidationError('¡El número de chasis no es válido!');
}
const car = new Car(
crypto.randomUUID(),
input.engine,
input.color,
false,
CarDocumentStatus.VALID,
input.chassi,
);
await this.repository.save(car);
return { carId: car.id };
}
}
Justo después, crea el caso de uso de actualización, que queda más o menos así:
class UpdateCarUseCase {
constructor(private readonly repository: ICarRepository) {}
async execute(carId: string, updates: Partial<CreateNewCarInput>): Promise<void> {
const car = await this.repository.findById(carId);
if (updates.engine) car.engine = updates.engine;
if (updates.color) car.color = updates.color;
if (updates.chassi) car.chassi = updates.chassi;
if (updates.chassi && !/^\d[A-HJ-NP-PR-Z]{4}\d{2}[A-HJ-NP-PR-Z]{4}\d{6}$/.test(updates.chassi)) {
throw new ValidationError('¡El número de chasis no es válido!');
}
await this.repository.save(car);
}
}
Al crear y analizar este código, el desarrollador podría decir: «ahora sigo la arquitectura limpia y tengo un dominio rico». ¿Tiene razón? ¿O este código necesita correcciones?
La entidad anémica
Analicemos primero la entidad Car. Al observarla, ¿dirías que esta clase tiene encapsulamiento? Planteemos algunas preguntas para reflexionar:
- Como todos los atributos son públicos, ¿qué impediría a quien la usa cambiar el identificador después de creada la entidad?
- ¿Qué impediría cambiar el atributo de encendido sin validar otras reglas?
- En cuanto al chasis, hay una validación de formato en ambos casos de uso. Pero ¿esa regla es inherente a la creación y actualización del auto, o al propio chasis?
- ¿Qué impediría que otra implementación cree o modifique un auto sin validar el formato del chasis, ya que la entidad lo trata como un
string? - ¿Podrían los valores iniciales por defecto (como el estado del documento y el encendido) ser distintos, ya que quien los define es el caso de uso de creación?
Al crear clases de entidad que solo tienen mapeo de campos, un constructor y métodos getters y setters, no estás construyendo un dominio rico ni siguiendo el patrón Domain Model. Al contrario: estás construyendo un antipatrón conocido como entidad anémica.
Como señala el propio Fowler, las entidades anémicas pueden confundir a primera vista, porque usan la nomenclatura del dominio y se relacionan con objetos de dominio. Pero, al analizarlas más a fondo, se ve que son solo una bolsa de getters y setters: ni más ni menos que diseño procedural, completamente contrario a la orientación a objetos y rompiendo uno de sus pilares: el encapsulamiento.
Migrando al modelo de dominio
Es hora de que nuestra entidad asuma sus responsabilidades. Primero, creemos una clase que encapsule toda la regla referida al chasis. Cualquier implementación que necesite un atributo de chasis podrá usarla.
export class Chassi {
private readonly value: string;
constructor(inputValue: string) {
if (!/^\d[A-HJ-NP-PR-Z]{4}\d{2}[A-HJ-NP-PR-Z]{4}\d{6}$/.test(inputValue)) {
throw new DomainError('¡El número de chasis no es válido!');
}
this.value = inputValue;
}
public getChassi(): string {
return this.value;
}
}
Ahora podemos rehacer la clase auto protegiendo los datos que no deben cambiar y con un método de creación que define los valores por defecto, para que no haya inconsistencias.
class Car {
public readonly id: string;
private readonly engine: string;
private readonly color: string;
private ignitionOn: boolean;
private documentStatus: CarDocumentStatus;
private readonly chassi: Chassi;
constructor(
id: string,
engine: string,
color: string,
ignitionOn: boolean,
documentStatus: CarDocumentStatus,
chassi: Chassi,
) {
this.id = id;
this.engine = engine;
this.color = color;
this.ignitionOn = ignitionOn;
this.documentStatus = documentStatus;
this.chassi = chassi;
}
public static create(engine: string, color: string, chassi: string): Car {
const carChassi = new Chassi(chassi);
return new Car(crypto.randomUUID(), engine, color, false, CarDocumentStatus.VALID, carChassi);
}
public getEngine(): string { return this.engine; }
public getColor(): string { return this.color; }
public getIgnitionOn(): boolean { return this.ignitionOn; }
public getDocumentStatus(): CarDocumentStatus { return this.documentStatus; }
public getChassi(): string { return this.chassi.getChassi(); }
}
Observa que la mayoría de los atributos son privados: para cambiar sus valores, hay que pasar por las reglas internas de la clase. El identificador, aunque público, es de solo lectura: una vez definido al construir la entidad, nunca podrá modificarse.
Además, la entidad tiene un método de creación que usa el objeto Chassi, responsable de impedir valores de chasis inválidos. El caso de uso puede entonces simplificarse a algo como esto:
class CreateNewCarUseCase {
constructor(private readonly repository: ICarRepository) {}
public async execute(input: CreateNewCarInput): Promise<{ id: string }> {
const entity = Car.create(input.engine, input.color, input.chassi);
await this.repository.save(entity);
return { id: entity.id };
}
}
¿Y cómo podría estructurarse el caso de uso de actualización? Primero, necesitamos crear los métodos de actualización en la entidad. Como solo el color y el motor pueden cambiar, solo se crean esos métodos, y esos dos atributos dejan de ser readonly:
class Car {
public readonly id: string;
private engine: string;
private color: string;
private ignitionOn: boolean;
private documentStatus: CarDocumentStatus;
private readonly chassi: Chassi;
// constructor, create() y getters como en el ejemplo anterior
public alterColor(newColor: string): void {
if (!newColor) throw new DomainError('¡El nuevo color del auto no puede estar vacío!');
this.color = newColor;
}
public alterEngine(newEngine: string): void {
if (!newEngine) throw new DomainError('¡El nuevo motor del auto no puede estar vacío!');
this.engine = newEngine;
}
}
Así, el caso de uso puede quedar algo como esto:
class UpdateCarUseCase {
constructor(private readonly repository: ICarRepository) {}
async execute(carId: string, updates: Partial<CreateNewCarInput>): Promise<void> {
const car = await this.repository.findById(carId);
if (updates.engine) car.alterEngine(updates.engine);
if (updates.color) car.alterColor(updates.color);
await this.repository.save(car);
}
}
De esta forma, solo los atributos modificables pueden cambiarse, respetando las reglas de la aplicación y sin generar inconsistencias en los datos. Todo objeto que use la entidad auto deberá respetar sus reglas y obedecer sus restricciones, lo que reduce la posibilidad de que la regla central diverja según la implementación. Las reglas de objetos que no son necesariamente entidades, como el chasis, también quedan centralizadas. Además, disminuye la duplicación de código y los casos de uso quedan más simples, porque no son responsables de implementar reglas de negocio, solo de orquestarlas.
Conclusión
No puedo ser hipócrita y afirmar que solo estos cambios caracterizan una aplicación que implementa la arquitectura limpia. Aun así, podemos coincidir en que, con un cambio de diseño como este, tu aplicación estará en camino de tener un dominio rico, proteger sus invariantes, reducir inconsistencias y contar con una base de código mantenible y evolutiva.
Vale la pena destacar que cada aplicación tiene su contexto y no siempre debemos seguir caminos como estos. Los patrones no son leyes, solo recomendaciones que dependen mucho del contexto. Un ejemplo claro es CQRS (Command-Query Responsibility Segregation), que propone modelos distintos para mutaciones (comandos) y lecturas (consultas), ya que las transacciones de lectura no necesitan complejidad en sus objetos. Para los modelos de lectura, recomiendo seguir modelos más simples, para concentrarse en lo que realmente necesita complejidad.