La guía de ddd-lib
Agregados que acumulan todas sus reglas rotas, value objects que se validan solos y seguimiento de estado. Sobre @nestjs/cqrs, sin framework encima.
Instalar
npm i @nestjslatam/ddd-lib@4.0.0 @nestjs/cqrs
@nestjs/cqrs no es opcional: DddAggregateRoot extiende su AggregateRoot. Es dependencia par, así que la instalas tú y no acabas con dos copias distintas en el árbol.
Sin acento circunflejo
La API ha roto entre versiones mayores y el compilador no detecta la mayoría de los cambios. Clavar la versión exacta es lo que te deja actualizar cuando tú lo decidas y no cuando alguien publique.
La idea que lo organiza todo
La mayoría de las librerías de validación lanzan en el primer problema. Ésta no: recolecta.
const product = new Product({ name, description, price });
product.isValid; // false
product.brokenRules.getBrokenRules(); // las TRES, no la primera
Eso cambia lo que le devuelves a quien llama. En vez de «el precio es inválido» —y que lo arregle, lo reenvíe y descubra que el nombre también lo era— le mandas las tres de golpe, con el campo de cada una nombrado.
La contrapartida es que tú tienes que preguntar. Como la validación no lanza, una fábrica que no comprueba isValid devuelve tan tranquila un objeto que incumple sus propias invariantes.
Un value object
export class Name extends StringValueObject {
static create(value: string): Name {
const name = new Name(value);
if (!name.isValid) { // ← imprescindible
throw new BrokenRulesException('Name', name.brokenRules.getBrokenRules());
}
return name;
}
override addValidators(): void {
super.addValidators(); // ← imprescindible
this.validatorRules.add(new NameLengthValidator(this));
}
}
Las dos líneas marcadas son el 80 % de los fallos al empezar, y ninguna produce un error si falta. Sin la primera, entran objetos inválidos en silencio. Sin la segunda, los validadores de la clase base desaparecen y '' pasa a ser un nombre válido.
Bases disponibles: StringValueObject, NumberValueObject, DateValueObject, BooleanValueObject, IdValueObject, DddEnum y DddValueObject<T> para los compuestos.
Un agregado
export class Product extends DddAggregateRoot<Product, IProductProps> {
private constructor(props: IProductProps, id?: IdValueObject) {
super(props, { id }); // el id va en la BOLSA DE OPCIONES
this.trackingState.markAsNew();
}
static create(name: Name, description: Description, price: Price): Product {
const product = new Product({ name, description, price, status: ProductStatus.ACTIVE });
if (!product.isValid) {
throw new BrokenRulesException('Product', product.brokenRules.getBrokenRules());
}
product.apply(new ProductCreatedEvent(product.id.getValue()));
return product;
}
}
Tres detalles que cuestan una tarde: dos argumentos de tipo, el id dentro de { id }, y markAsNew() lo llamas tú — el seguimiento de estado no adivina, y es lo que después permite al repositorio elegir entre insertar y actualizar.
Junto a create() va load(), que no valida a propósito: rehidratar algo que ya está guardado no es lo mismo que crearlo. Si load() validara, endurecer una regla convertiría tus filas históricas en datos que no se pueden ni abrir para migrarlos.
Una regla que sólo el agregado puede ver
export class ProductBusinessRulesValidator extends AbstractRuleValidator<Product> {
public addRules(): void {
const { name, description } = this.subject.props;
// OJO: la condición es verdadera cuando la regla está ROTA.
if (description.getValue().length <= name.getValue().length) {
this.addBrokenRule('props.description', 'La descripción debe ser más larga que el nombre');
}
}
}
Aquí es donde el patrón paga. name es válido. description es válida. La combinación no lo es, y ningún value object puede saberlo porque ninguno ve al otro. Para eso existe la raíz de agregado.
Eventos de dominio
await this.repository.save(product); // 1. persistir
this.publisher.mergeObjectContext(product).commit(); // 2. despachar
apply() no despacha nada: añade el evento a una lista del agregado. Sólo commit() lo entrega al bus.
La línea que se olvida
Si falta commit(), el comando se ejecuta con éxito, devuelve su respuesta, no lanza ningún error… y todos tus @EventsHandler se saltan en silencio. El correo de confirmación simplemente no se envía, y lo descubres tres días después.
Y el orden importa: publicar antes de guardar abre una ventana en la que un manejador lee algo que todavía no existe.
Traducir el dominio a HTTP
Falta un campo o es del tipo equivocado. Lo rechaza el ValidationPipe antes de que el dominio lo vea.
El cuerpo está bien formado y el agregado lo rechaza. price: 0 es un número válido; que no pueda ser un precio es conocimiento de negocio.
Nada está mal formado y ningún valor es inválido: el agregado no está en un estado que permita la operación.
Todo lo que no sea una excepción de dominio se deja como 500. Un TypeError disfrazado de 400 es un bug que nadie va a investigar.
Las cuatro trampas
Las cuatro compilan, las cuatro pasan los tests, y npx ddd validate las detecta todas.
isValid
Devuelve objetos inválidos. Sin síntoma hasta que aparecen como datos imposibles en un informe.
super.addValidators()
Los validadores de la base desaparecen. Sin síntoma.
addValidators()
El constructor base lo llama antes que el tuyo, así que el campo es undefined. Revienta en cada construcción.
commit()
Ningún evento se despacha. Sin síntoma.
Seguir por aquí
La referencia completa —cada clase, cada método, cada código de estado— vive junto al código, versionada con él.
