Capítulo 7: Modelando Eventos del Dominio
Capítulo 7: Modelando Eventos del Dominio
“Cada evento debe contar una historia completa de lo que ocurrió”
Catalogo de Eventos
Antes de escribir código, es útil definir el catálogo de eventos: la lista completa de eventos que el sistema puede generar.
Para identificar eventos, pregúntate:
- ¿Qué acciones puede realizar el usuario?
- ¿Qué cambios de estado son importantes para el negocio?
- ¿Qué información necesitarán las proyecciones?
El diagrama de estados es una herramienta excelente para visualizar el ciclo de vida de una entidad y los eventos que disparan transiciones.
Para nuestro sistema de pedidos, necesitamos eventos que capturen todo el ciclo de vida:
stateDiagram-v2
[*] --> Draft: OrderCreated
Draft --> Draft: ItemAdded/Removed
Draft --> Confirmed: OrderConfirmed
Confirmed --> Paid: PaymentReceived
Paid --> Shipped: OrderShipped
Shipped --> Delivered: OrderDelivered
Draft --> Cancelled: OrderCancelled
Confirmed --> Cancelled: OrderCancelled
Implementacion de Eventos Base
Vamos a crear una estructura base para todos nuestros eventos. Esta incluye:
- DomainEvent
: Interface genérica que define la estructura común de todos los eventos - EventMetadata: Información contextual (correlationId, causationId, userId)
- createEvent: Función factory para crear eventos con valores por defecto
// src/domain/events/base.ts
import { z } from 'zod';
import { v4 as uuid } from 'uuid';
export const EventMetadataSchema = z.object({
correlationId: z.string().uuid(),
causationId: z.string().uuid(),
userId: z.string().uuid().optional(),
timestamp: z.date()
});
export type EventMetadata = z.infer<typeof EventMetadataSchema>;
export interface DomainEvent<T = unknown> {
eventId: string;
eventType: string;
aggregateId: string;
aggregateType: string;
version: number;
payload: T;
metadata: EventMetadata;
}
export function createMetadata(
userId?: string,
correlationId?: string,
causationId?: string
): EventMetadata {
const corrId = correlationId ?? uuid();
return {
correlationId: corrId,
causationId: causationId ?? corrId,
userId,
timestamp: new Date()
};
}
export function createEvent<T>(
eventType: string,
aggregateId: string,
aggregateType: string,
version: number,
payload: T,
metadata?: Partial<EventMetadata>
): DomainEvent<T> {
return {
eventId: uuid(),
eventType,
aggregateId,
aggregateType,
version,
payload,
metadata: {
...createMetadata(),
...metadata
}
};
}
Value Objects
Los value objects son objetos inmutables que representan conceptos del dominio. A diferencia de las entidades (que tienen identidad), los value objects se definen por sus atributos.
Usamos Zod para definir schemas que:
- Documentan la estructura esperada
- Validan datos en tiempo de ejecución
- Generan tipos TypeScript automáticamente
// src/domain/value-objects/address.ts
import { z } from 'zod';
export const AddressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().min(1),
zipCode: z.string().min(1),
country: z.string().length(2) // ISO 3166-1 alpha-2
});
export type Address = z.infer<typeof AddressSchema>;
// src/domain/value-objects/money.ts
export const MoneySchema = z.object({
amount: z.number().nonnegative(),
currency: z.string().length(3) // ISO 4217
});
export type Money = z.infer<typeof MoneySchema>;
// src/domain/value-objects/order-item.ts
export const OrderItemSchema = z.object({
productId: z.string().uuid(),
productName: z.string().min(1),
sku: z.string().min(1),
quantity: z.number().int().positive(),
unitPrice: MoneySchema
});
export type OrderItem = z.infer<typeof OrderItemSchema>;
Eventos del Pedido
// src/domain/events/order-events.ts
import { z } from 'zod';
import { AddressSchema, OrderItemSchema, MoneySchema } from '../value-objects';
import { createEvent, DomainEvent } from './base';
// ============ ORDER CREATED ============
export const OrderCreatedPayloadSchema = z.object({
customerId: z.string().uuid(),
customerEmail: z.string().email(),
items: z.array(OrderItemSchema).min(1),
shippingAddress: AddressSchema,
billingAddress: AddressSchema.optional(),
subtotal: MoneySchema,
tax: MoneySchema,
total: MoneySchema
});
export type OrderCreatedPayload = z.infer<typeof OrderCreatedPayloadSchema>;
export type OrderCreated = DomainEvent<OrderCreatedPayload>;
export const createOrderCreated = (
orderId: string,
version: number,
payload: OrderCreatedPayload
): OrderCreated => createEvent(
'OrderCreated',
orderId,
'Order',
version,
payload
);
// ============ ORDER ITEM ADDED ============
export const OrderItemAddedPayloadSchema = z.object({
item: OrderItemSchema,
newSubtotal: MoneySchema,
newTotal: MoneySchema
});
export type OrderItemAddedPayload = z.infer<typeof OrderItemAddedPayloadSchema>;
export type OrderItemAdded = DomainEvent<OrderItemAddedPayload>;
export const createOrderItemAdded = (
orderId: string,
version: number,
payload: OrderItemAddedPayload
): OrderItemAdded => createEvent(
'OrderItemAdded',
orderId,
'Order',
version,
payload
);
// ============ ORDER ITEM REMOVED ============
export const OrderItemRemovedPayloadSchema = z.object({
productId: z.string().uuid(),
newSubtotal: MoneySchema,
newTotal: MoneySchema
});
export type OrderItemRemovedPayload = z.infer<typeof OrderItemRemovedPayloadSchema>;
export type OrderItemRemoved = DomainEvent<OrderItemRemovedPayload>;
// ============ ORDER ITEM QUANTITY UPDATED ============
export const OrderItemQuantityUpdatedPayloadSchema = z.object({
productId: z.string().uuid(),
oldQuantity: z.number().int().positive(),
newQuantity: z.number().int().positive(),
newSubtotal: MoneySchema,
newTotal: MoneySchema
});
export type OrderItemQuantityUpdatedPayload = z.infer<typeof OrderItemQuantityUpdatedPayloadSchema>;
// ============ SHIPPING ADDRESS UPDATED ============
export const ShippingAddressUpdatedPayloadSchema = z.object({
oldAddress: AddressSchema,
newAddress: AddressSchema
});
export type ShippingAddressUpdatedPayload = z.infer<typeof ShippingAddressUpdatedPayloadSchema>;
// ============ ORDER CONFIRMED ============
export const OrderConfirmedPayloadSchema = z.object({
confirmedAt: z.date(),
estimatedDelivery: z.date().optional()
});
export type OrderConfirmedPayload = z.infer<typeof OrderConfirmedPayloadSchema>;
export type OrderConfirmed = DomainEvent<OrderConfirmedPayload>;
// ============ PAYMENT RECEIVED ============
export const PaymentReceivedPayloadSchema = z.object({
paymentId: z.string().uuid(),
amount: MoneySchema,
method: z.enum(['credit_card', 'debit_card', 'paypal', 'bank_transfer']),
transactionId: z.string(),
paidAt: z.date()
});
export type PaymentReceivedPayload = z.infer<typeof PaymentReceivedPayloadSchema>;
// ============ ORDER SHIPPED ============
export const OrderShippedPayloadSchema = z.object({
trackingNumber: z.string().min(1),
carrier: z.string().min(1),
shippedAt: z.date(),
estimatedDelivery: z.date()
});
export type OrderShippedPayload = z.infer<typeof OrderShippedPayloadSchema>;
// ============ ORDER DELIVERED ============
export const OrderDeliveredPayloadSchema = z.object({
deliveredAt: z.date(),
signedBy: z.string().optional(),
proofOfDeliveryUrl: z.string().url().optional()
});
export type OrderDeliveredPayload = z.infer<typeof OrderDeliveredPayloadSchema>;
// ============ ORDER CANCELLED ============
export const OrderCancelledPayloadSchema = z.object({
reason: z.string().min(1),
cancelledBy: z.enum(['customer', 'system', 'admin']),
cancelledAt: z.date(),
refundRequired: z.boolean()
});
export type OrderCancelledPayload = z.infer<typeof OrderCancelledPayloadSchema>;
export type OrderCancelled = DomainEvent<OrderCancelledPayload>;
// ============ ORDER REFUNDED ============
export const OrderRefundedPayloadSchema = z.object({
refundId: z.string().uuid(),
amount: MoneySchema,
reason: z.string(),
refundedAt: z.date()
});
export type OrderRefundedPayload = z.infer<typeof OrderRefundedPayloadSchema>;
// ============ UNION TYPE ============
export type OrderEvent =
| OrderCreated
| OrderItemAdded
| OrderItemRemoved
| DomainEvent<OrderItemQuantityUpdatedPayload>
| DomainEvent<ShippingAddressUpdatedPayload>
| OrderConfirmed
| DomainEvent<PaymentReceivedPayload>
| DomainEvent<OrderShippedPayload>
| DomainEvent<OrderDeliveredPayload>
| OrderCancelled
| DomainEvent<OrderRefundedPayload>;
Eventos de Inventario
// src/domain/events/inventory-events.ts
import { z } from 'zod';
import { createEvent, DomainEvent } from './base';
// ============ STOCK RESERVED ============
export const StockReservedPayloadSchema = z.object({
orderId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
previousStock: z.number().int().nonnegative(),
newStock: z.number().int().nonnegative()
})),
reservedAt: z.date(),
expiresAt: z.date()
});
export type StockReservedPayload = z.infer<typeof StockReservedPayloadSchema>;
// ============ STOCK RELEASED ============
export const StockReleasedPayloadSchema = z.object({
orderId: z.string().uuid(),
reason: z.enum(['order_cancelled', 'reservation_expired', 'manual']),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive()
})),
releasedAt: z.date()
});
export type StockReleasedPayload = z.infer<typeof StockReleasedPayloadSchema>;
// ============ STOCK CONFIRMED ============
export const StockConfirmedPayloadSchema = z.object({
orderId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive()
})),
confirmedAt: z.date()
});
export type StockConfirmedPayload = z.infer<typeof StockConfirmedPayloadSchema>;
// ============ LOW STOCK ALERT ============
export const LowStockAlertPayloadSchema = z.object({
productId: z.string().uuid(),
currentStock: z.number().int().nonnegative(),
threshold: z.number().int().positive(),
alertedAt: z.date()
});
export type LowStockAlertPayload = z.infer<typeof LowStockAlertPayloadSchema>;
Testing de Eventos
// src/domain/events/order-events.test.ts
import { describe, it, expect } from 'vitest';
import {
OrderCreatedPayloadSchema,
createOrderCreated
} from './order-events';
describe('Order Events', () => {
describe('OrderCreated', () => {
const validPayload = {
customerId: '550e8400-e29b-41d4-a716-446655440000',
customerEmail: '[email protected]',
items: [{
productId: '550e8400-e29b-41d4-a716-446655440001',
productName: 'Widget Pro',
sku: 'WDG-PRO-001',
quantity: 2,
unitPrice: { amount: 29.99, currency: 'USD' }
}],
shippingAddress: {
street: '123 Main St',
city: 'Springfield',
state: 'IL',
zipCode: '62701',
country: 'US'
},
subtotal: { amount: 59.98, currency: 'USD' },
tax: { amount: 4.80, currency: 'USD' },
total: { amount: 64.78, currency: 'USD' }
};
it('should validate correct payload', () => {
const result = OrderCreatedPayloadSchema.safeParse(validPayload);
expect(result.success).toBe(true);
});
it('should reject empty items', () => {
const invalid = { ...validPayload, items: [] };
const result = OrderCreatedPayloadSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it('should reject invalid email', () => {
const invalid = { ...validPayload, customerEmail: 'not-an-email' };
const result = OrderCreatedPayloadSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it('should create event with correct structure', () => {
const event = createOrderCreated('order-123', 1, validPayload);
expect(event.eventType).toBe('OrderCreated');
expect(event.aggregateId).toBe('order-123');
expect(event.aggregateType).toBe('Order');
expect(event.version).toBe(1);
expect(event.payload).toEqual(validPayload);
expect(event.eventId).toBeDefined();
expect(event.metadata.correlationId).toBeDefined();
});
});
});
Resumen
- Cada evento tiene un schema Zod para validación
- Los eventos incluyen toda la información relevante
- Los Value Objects encapsulan conceptos del dominio
- Los eventos forman un catálogo completo del ciclo de vida
- El testing valida estructura y reglas de negocio
Glosario
Catalogo de Eventos
Definicion: Lista completa y documentada de todos los tipos de eventos que un sistema puede generar, incluyendo su estructura y significado de negocio.
Por que es importante: Sirve como contrato entre equipos. Todos saben que eventos existen, que significan, y que datos contienen. Facilita el diseno de proyecciones.
Ejemplo practico: Para el agregado Order: OrderCreated, OrderItemAdded, OrderConfirmed, PaymentReceived, OrderShipped, OrderDelivered, OrderCancelled. Cada uno con su schema documentado.
Diagrama de Estados
Definicion: Representacion visual de los estados posibles de una entidad y las transiciones (eventos) que causan cambios de estado.
Por que es importante: Ayuda a identificar todos los eventos necesarios y validar que las transiciones tienen sentido de negocio.
Ejemplo practico: Draft --[OrderConfirmed]--> Confirmed --[PaymentReceived]--> Paid --[OrderShipped]--> Shipped. El diagrama muestra que no puedes ir de Draft a Shipped directamente.
Zod
Definicion: Libreria de TypeScript para definir schemas de validacion que generan tipos estaticos automaticamente.
Por que es importante: Combina validacion en tiempo de ejecucion con tipos en tiempo de compilacion. Un solo schema sirve para documentar, validar, y tipar.
Ejemplo practico: const AddressSchema = z.object({ street: z.string(), city: z.string() }) define un schema. AddressSchema.parse(data) valida. z.infer<typeof AddressSchema> genera el tipo.
Type Inference (Inferencia de Tipos)
Definicion: Capacidad del compilador de TypeScript de deducir tipos automaticamente basandose en el codigo.
Por que es importante: Con Zod, defines el schema una vez y TypeScript infiere el tipo. No necesitas mantener tipos y validaciones sincronizados manualmente.
Ejemplo practico: type OrderCreatedPayload = z.infer<typeof OrderCreatedPayloadSchema> genera automaticamente el tipo a partir del schema Zod.
Frozen/Immutable Objects
Definicion: Objetos que no pueden modificarse despues de crearse. Cualquier “cambio” crea un nuevo objeto.
Por que es importante: Los eventos DEBEN ser inmutables. Si modificas un evento despues de guardarlo, corrompes la fuente de verdad.
Ejemplo practico: En Python, @dataclass(frozen=True) hace que el dataclass sea inmutable. En TypeScript, readonly en propiedades previene modificaciones.
Union Type
Definicion: Tipo que puede ser uno de varios tipos especificados. En TypeScript: type A = B | C | D.
Por que es importante: Permite representar “el evento puede ser OrderCreated O OrderShipped O OrderCancelled”. El compilador garantiza que manejas todos los casos.
Ejemplo practico: type OrderEvent = OrderCreated | OrderItemAdded | OrderConfirmed. En un switch sobre event.eventType, TypeScript te obliga a manejar todos los tipos.
Schema de Validacion
Definicion: Definicion formal de la estructura, tipos, y restricciones de un objeto de datos.
Por que es importante: Previene datos invalidos de entrar al sistema. Especialmente critico para eventos que son inmutables una vez guardados.
Ejemplo practico: quantity: z.number().int().positive() valida que quantity sea un entero positivo. Si alguien intenta guardar -5, Zod lanza error antes de persistir.
UUID (Universally Unique Identifier)
Definicion: Identificador de 128 bits disenado para ser unico sin coordinacion central.
Por que es importante: Permite generar IDs unicos en cualquier nodo sin consultar una base de datos. Esencial para sistemas distribuidos.
Ejemplo practico: uuid() genera algo como "550e8400-e29b-41d4-a716-446655440000". Dos llamadas a uuid() nunca generaran el mismo valor.
ISO 4217 / ISO 3166
Definicion: Estandares internacionales para codigos de moneda (USD, EUR) y codigos de pais (US, ES).
Por que es importante: Usar estandares garantiza consistencia y facilita integraciones. Todo el mundo sabe que “USD” significa dolares americanos.
Ejemplo practico: currency: z.string().length(3) valida que sea un codigo de 3 letras (ISO 4217). country: z.string().length(2) para codigo de pais (ISO 3166-1 alpha-2).
← Capítulo 6: Setup TypeScript | Capítulo 8: Event Store con PostgreSQL →