← Volver al listado de tecnologías

Capítulo 7: Modelando Eventos del Dominio

Por: SiempreListo
event-sourcingeventosdominiotypescript

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:

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:

// 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:

  1. Documentan la estructura esperada
  2. Validan datos en tiempo de ejecución
  3. 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

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 →