← Volver al listado de tecnologías

Capítulo 9: Agregado Order - Comandos y Eventos

Por: SiempreListo
event-sourcingagregadosordertypescript

Capítulo 9: Agregado Order - Comandos y Eventos

“El agregado protege las invariantes del dominio”

Este capítulo implementa el agregado Order completo. Verás cómo:

  1. El estado interno se mantiene privado
  2. Los comandos (métodos públicos) validan reglas de negocio antes de generar eventos
  3. Los eventos se aplican para actualizar el estado
  4. El agregado puede rehidratarse desde eventos históricos

Estados del Pedido

Definimos primero los tipos que representan el estado interno del agregado. Nota que usamos Map para los items, lo que facilita búsquedas por productId.

// src/domain/aggregates/order/types.ts
export type OrderStatus =
  | 'draft'
  | 'confirmed'
  | 'paid'
  | 'shipped'
  | 'delivered'
  | 'cancelled';

export interface OrderState {
  id: string;
  customerId: string;
  customerEmail: string;
  items: Map<string, OrderItemState>;
  shippingAddress: Address | null;
  billingAddress: Address | null;
  status: OrderStatus;
  subtotal: Money;
  tax: Money;
  total: Money;
  paymentId: string | null;
  trackingNumber: string | null;
  version: number;
}

export interface OrderItemState {
  productId: string;
  productName: string;
  sku: string;
  quantity: number;
  unitPrice: Money;
}

Implementacion del Agregado

La implementación sigue un patrón específico:

// src/domain/aggregates/order/order.ts
import { v4 as uuid } from 'uuid';
import { DomainEvent } from '@domain/events/base';
import {
  OrderCreatedPayload,
  OrderItemAddedPayload,
  OrderConfirmedPayload,
  PaymentReceivedPayload,
  OrderShippedPayload,
  OrderCancelledPayload
} from '@domain/events/order-events';
import { Address, Money, OrderItem } from '@domain/value-objects';
import { OrderState, OrderStatus, OrderItemState } from './types';

export class Order {
  private state: OrderState;
  private uncommittedEvents: DomainEvent[] = [];

  private constructor() {
    this.state = this.initialState();
  }

  private initialState(): OrderState {
    return {
      id: '',
      customerId: '',
      customerEmail: '',
      items: new Map(),
      shippingAddress: null,
      billingAddress: null,
      status: 'draft',
      subtotal: { amount: 0, currency: 'USD' },
      tax: { amount: 0, currency: 'USD' },
      total: { amount: 0, currency: 'USD' },
      paymentId: null,
      trackingNumber: null,
      version: -1
    };
  }

  // ============ GETTERS ============
  get id(): string { return this.state.id; }
  get customerId(): string { return this.state.customerId; }
  get status(): OrderStatus { return this.state.status; }
  get version(): number { return this.state.version; }
  get total(): Money { return this.state.total; }
  get items(): OrderItemState[] { return [...this.state.items.values()]; }

  getUncommittedEvents(): DomainEvent[] {
    return [...this.uncommittedEvents];
  }

  clearUncommittedEvents(): void {
    this.uncommittedEvents = [];
  }

  // ============ FACTORY METHODS ============
  static create(
    customerId: string,
    customerEmail: string,
    items: OrderItem[],
    shippingAddress: Address,
    billingAddress?: Address
  ): Order {
    if (items.length === 0) {
      throw new Error('Order must have at least one item');
    }

    const order = new Order();
    const orderId = uuid();

    const subtotal = items.reduce(
      (sum, item) => sum + item.unitPrice.amount * item.quantity,
      0
    );
    const tax = subtotal * 0.08; // 8% tax
    const total = subtotal + tax;

    const payload: OrderCreatedPayload = {
      customerId,
      customerEmail,
      items,
      shippingAddress,
      billingAddress,
      subtotal: { amount: subtotal, currency: 'USD' },
      tax: { amount: tax, currency: 'USD' },
      total: { amount: total, currency: 'USD' }
    };

    order.apply({
      eventId: uuid(),
      eventType: 'OrderCreated',
      aggregateId: orderId,
      aggregateType: 'Order',
      version: 0,
      payload,
      metadata: {
        correlationId: uuid(),
        causationId: uuid(),
        timestamp: new Date()
      }
    });

    return order;
  }

  static fromEvents(events: DomainEvent[]): Order {
    if (events.length === 0) {
      throw new Error('Cannot rehydrate order without events');
    }

    const order = new Order();
    events.forEach(event => order.when(event));
    return order;
  }

  // ============ COMMANDS ============
  addItem(item: OrderItem): void {
    this.assertStatus('draft', 'add items');

    if (this.state.items.has(item.productId)) {
      throw new Error(`Product ${item.productId} already in order`);
    }

    const newSubtotal = this.state.subtotal.amount +
      (item.unitPrice.amount * item.quantity);
    const newTax = newSubtotal * 0.08;
    const newTotal = newSubtotal + newTax;

    const payload: OrderItemAddedPayload = {
      item,
      newSubtotal: { amount: newSubtotal, currency: 'USD' },
      newTotal: { amount: newTotal, currency: 'USD' }
    };

    this.applyNew('OrderItemAdded', payload);
  }

  removeItem(productId: string): void {
    this.assertStatus('draft', 'remove items');

    const item = this.state.items.get(productId);
    if (!item) {
      throw new Error(`Product ${productId} not in order`);
    }

    if (this.state.items.size === 1) {
      throw new Error('Cannot remove last item. Cancel order instead.');
    }

    const removedAmount = item.unitPrice.amount * item.quantity;
    const newSubtotal = this.state.subtotal.amount - removedAmount;
    const newTax = newSubtotal * 0.08;
    const newTotal = newSubtotal + newTax;

    this.applyNew('OrderItemRemoved', {
      productId,
      newSubtotal: { amount: newSubtotal, currency: 'USD' },
      newTotal: { amount: newTotal, currency: 'USD' }
    });
  }

  updateItemQuantity(productId: string, newQuantity: number): void {
    this.assertStatus('draft', 'update quantities');

    if (newQuantity <= 0) {
      throw new Error('Quantity must be positive');
    }

    const item = this.state.items.get(productId);
    if (!item) {
      throw new Error(`Product ${productId} not in order`);
    }

    const oldTotal = item.unitPrice.amount * item.quantity;
    const newItemTotal = item.unitPrice.amount * newQuantity;
    const newSubtotal = this.state.subtotal.amount - oldTotal + newItemTotal;
    const newTax = newSubtotal * 0.08;
    const newTotal = newSubtotal + newTax;

    this.applyNew('OrderItemQuantityUpdated', {
      productId,
      oldQuantity: item.quantity,
      newQuantity,
      newSubtotal: { amount: newSubtotal, currency: 'USD' },
      newTotal: { amount: newTotal, currency: 'USD' }
    });
  }

  updateShippingAddress(address: Address): void {
    this.assertStatus('draft', 'update shipping address');

    this.applyNew('ShippingAddressUpdated', {
      oldAddress: this.state.shippingAddress!,
      newAddress: address
    });
  }

  confirm(): void {
    this.assertStatus('draft', 'confirm');

    if (this.state.items.size === 0) {
      throw new Error('Cannot confirm empty order');
    }

    if (!this.state.shippingAddress) {
      throw new Error('Shipping address is required');
    }

    const payload: OrderConfirmedPayload = {
      confirmedAt: new Date(),
      estimatedDelivery: this.calculateEstimatedDelivery()
    };

    this.applyNew('OrderConfirmed', payload);
  }

  receivePayment(
    paymentId: string,
    amount: Money,
    method: 'credit_card' | 'debit_card' | 'paypal' | 'bank_transfer',
    transactionId: string
  ): void {
    this.assertStatus('confirmed', 'receive payment');

    if (amount.amount < this.state.total.amount) {
      throw new Error('Payment amount is less than order total');
    }

    const payload: PaymentReceivedPayload = {
      paymentId,
      amount,
      method,
      transactionId,
      paidAt: new Date()
    };

    this.applyNew('PaymentReceived', payload);
  }

  ship(trackingNumber: string, carrier: string): void {
    this.assertStatus('paid', 'ship');

    const payload: OrderShippedPayload = {
      trackingNumber,
      carrier,
      shippedAt: new Date(),
      estimatedDelivery: this.calculateEstimatedDelivery()
    };

    this.applyNew('OrderShipped', payload);
  }

  markDelivered(signedBy?: string): void {
    this.assertStatus('shipped', 'mark as delivered');

    this.applyNew('OrderDelivered', {
      deliveredAt: new Date(),
      signedBy
    });
  }

  cancel(reason: string, cancelledBy: 'customer' | 'system' | 'admin'): void {
    const nonCancellableStatuses: OrderStatus[] = ['shipped', 'delivered', 'cancelled'];

    if (nonCancellableStatuses.includes(this.state.status)) {
      throw new Error(`Cannot cancel order in status: ${this.state.status}`);
    }

    const payload: OrderCancelledPayload = {
      reason,
      cancelledBy,
      cancelledAt: new Date(),
      refundRequired: this.state.status === 'paid'
    };

    this.applyNew('OrderCancelled', payload);
  }

  // ============ EVENT HANDLERS ============
  private when(event: DomainEvent): void {
    switch (event.eventType) {
      case 'OrderCreated':
        this.onOrderCreated(event.payload as OrderCreatedPayload, event);
        break;
      case 'OrderItemAdded':
        this.onOrderItemAdded(event.payload as OrderItemAddedPayload);
        break;
      case 'OrderItemRemoved':
        this.onOrderItemRemoved(event.payload as { productId: string });
        break;
      case 'OrderItemQuantityUpdated':
        this.onOrderItemQuantityUpdated(event.payload as any);
        break;
      case 'ShippingAddressUpdated':
        this.onShippingAddressUpdated(event.payload as any);
        break;
      case 'OrderConfirmed':
        this.state.status = 'confirmed';
        break;
      case 'PaymentReceived':
        this.onPaymentReceived(event.payload as PaymentReceivedPayload);
        break;
      case 'OrderShipped':
        this.onOrderShipped(event.payload as OrderShippedPayload);
        break;
      case 'OrderDelivered':
        this.state.status = 'delivered';
        break;
      case 'OrderCancelled':
        this.state.status = 'cancelled';
        break;
    }

    this.state.version = event.version;
  }

  private onOrderCreated(payload: OrderCreatedPayload, event: DomainEvent): void {
    this.state.id = event.aggregateId;
    this.state.customerId = payload.customerId;
    this.state.customerEmail = payload.customerEmail;
    this.state.shippingAddress = payload.shippingAddress;
    this.state.billingAddress = payload.billingAddress ?? null;
    this.state.subtotal = payload.subtotal;
    this.state.tax = payload.tax;
    this.state.total = payload.total;
    this.state.status = 'draft';

    payload.items.forEach(item => {
      this.state.items.set(item.productId, {
        productId: item.productId,
        productName: item.productName,
        sku: item.sku,
        quantity: item.quantity,
        unitPrice: item.unitPrice
      });
    });
  }

  private onOrderItemAdded(payload: OrderItemAddedPayload): void {
    const item = payload.item;
    this.state.items.set(item.productId, {
      productId: item.productId,
      productName: item.productName,
      sku: item.sku,
      quantity: item.quantity,
      unitPrice: item.unitPrice
    });
    this.state.subtotal = payload.newSubtotal;
    this.state.total = payload.newTotal;
  }

  private onOrderItemRemoved(payload: { productId: string }): void {
    this.state.items.delete(payload.productId);
  }

  private onOrderItemQuantityUpdated(payload: any): void {
    const item = this.state.items.get(payload.productId);
    if (item) {
      item.quantity = payload.newQuantity;
    }
    this.state.subtotal = payload.newSubtotal;
    this.state.total = payload.newTotal;
  }

  private onShippingAddressUpdated(payload: any): void {
    this.state.shippingAddress = payload.newAddress;
  }

  private onPaymentReceived(payload: PaymentReceivedPayload): void {
    this.state.paymentId = payload.paymentId;
    this.state.status = 'paid';
  }

  private onOrderShipped(payload: OrderShippedPayload): void {
    this.state.trackingNumber = payload.trackingNumber;
    this.state.status = 'shipped';
  }

  // ============ HELPERS ============
  private apply(event: DomainEvent): void {
    this.uncommittedEvents.push(event);
    this.when(event);
  }

  private applyNew(eventType: string, payload: unknown): void {
    this.apply({
      eventId: uuid(),
      eventType,
      aggregateId: this.state.id,
      aggregateType: 'Order',
      version: this.state.version + 1,
      payload,
      metadata: {
        correlationId: uuid(),
        causationId: uuid(),
        timestamp: new Date()
      }
    });
  }

  private assertStatus(expected: OrderStatus, action: string): void {
    if (this.state.status !== expected) {
      throw new Error(
        `Cannot ${action} when order is in status: ${this.state.status}`
      );
    }
  }

  private calculateEstimatedDelivery(): Date {
    const delivery = new Date();
    delivery.setDate(delivery.getDate() + 5); // 5 business days
    return delivery;
  }
}

Testing del Agregado

// src/domain/aggregates/order/order.test.ts
import { describe, it, expect } from 'vitest';
import { Order } from './order';

describe('Order Aggregate', () => {
  const defaultItem = {
    productId: '550e8400-e29b-41d4-a716-446655440001',
    productName: 'Widget',
    sku: 'WDG-001',
    quantity: 2,
    unitPrice: { amount: 25, currency: 'USD' }
  };

  const defaultAddress = {
    street: '123 Main St',
    city: 'Springfield',
    state: 'IL',
    zipCode: '62701',
    country: 'US'
  };

  describe('create', () => {
    it('should create order with correct totals', () => {
      const order = Order.create(
        'customer-1',
        '[email protected]',
        [defaultItem],
        defaultAddress
      );

      expect(order.status).toBe('draft');
      expect(order.total.amount).toBe(54); // 50 + 8% tax
      expect(order.items).toHaveLength(1);
    });

    it('should reject empty items', () => {
      expect(() => Order.create(
        'customer-1',
        '[email protected]',
        [],
        defaultAddress
      )).toThrow('at least one item');
    });
  });

  describe('confirm', () => {
    it('should confirm valid order', () => {
      const order = Order.create(
        'customer-1',
        '[email protected]',
        [defaultItem],
        defaultAddress
      );

      order.confirm();

      expect(order.status).toBe('confirmed');
    });

    it('should reject confirmation without shipping address', () => {
      // This would require modifying create to allow null address
      // For now, we test that confirm works with address
      const order = Order.create(
        'customer-1',
        '[email protected]',
        [defaultItem],
        defaultAddress
      );

      order.confirm();
      expect(order.status).toBe('confirmed');
    });
  });

  describe('state transitions', () => {
    it('should follow correct lifecycle', () => {
      const order = Order.create(
        'customer-1',
        '[email protected]',
        [defaultItem],
        defaultAddress
      );

      // Draft -> Confirmed
      order.confirm();
      expect(order.status).toBe('confirmed');

      // Confirmed -> Paid
      order.receivePayment(
        'pay-1',
        { amount: 54, currency: 'USD' },
        'credit_card',
        'txn-123'
      );
      expect(order.status).toBe('paid');

      // Paid -> Shipped
      order.ship('TRACK123', 'FedEx');
      expect(order.status).toBe('shipped');

      // Shipped -> Delivered
      order.markDelivered('John Doe');
      expect(order.status).toBe('delivered');
    });

    it('should not allow invalid transitions', () => {
      const order = Order.create(
        'customer-1',
        '[email protected]',
        [defaultItem],
        defaultAddress
      );

      expect(() => order.receivePayment(
        'pay-1',
        { amount: 54, currency: 'USD' },
        'credit_card',
        'txn-123'
      )).toThrow('confirmed');
    });
  });

  describe('rehydration', () => {
    it('should rebuild state from events', () => {
      const original = Order.create(
        'customer-1',
        '[email protected]',
        [defaultItem],
        defaultAddress
      );
      original.confirm();

      const events = original.getUncommittedEvents();
      const rehydrated = Order.fromEvents(events);

      expect(rehydrated.id).toBe(original.id);
      expect(rehydrated.status).toBe('confirmed');
      expect(rehydrated.items).toHaveLength(1);
    });
  });
});

Resumen

Glosario

Estado Interno (Internal State)

Definicion: Los datos privados del agregado que representan su condicion actual. Solo se modifica mediante eventos.

Por que es importante: Encapsular el estado previene modificaciones directas que podrian violar invariantes. Todo cambio pasa por comandos que validan reglas.

Ejemplo practico: private state: OrderState contiene id, status, items, total. No hay setters publicos; solo metodos como addItem() que validan y generan eventos.


Comando (en contexto de Agregado)

Definicion: Metodo publico del agregado que representa una intencion de cambio. Valida reglas, genera eventos, y actualiza estado.

Por que es importante: Los comandos son el unico punto de entrada para modificar el agregado. Garantizan que todas las validaciones ocurran.

Ejemplo practico: order.confirm() verifica que hay items, que hay direccion, que el status es draft, y luego genera OrderConfirmed.


Event Handler (when/apply)

Definicion: Metodo interno que actualiza el estado del agregado basandose en un evento, sin validaciones ni efectos secundarios.

Por que es importante: Separa “decidir que evento generar” (comando) de “como el evento afecta el estado” (handler). El handler se reutiliza en rehidratacion.

Ejemplo practico: when(OrderConfirmed) simplemente hace this.state.status = 'confirmed'. No valida nada porque el evento ya ocurrio (es un hecho).


Guard (Assertion Methods)

Definicion: Metodos que verifican precondiciones y lanzan errores si no se cumplen. Protegen invariantes.

Por que es importante: Centralizan validaciones repetidas. assertStatus('draft', 'add items') se usa en addItem, removeItem, updateQuantity.

Ejemplo practico: assertStatus(expected, action) verifica que el pedido este en el estado esperado antes de permitir la accion, lanzando error descriptivo si no.


Maquina de Estados (State Machine)

Definicion: Modelo donde una entidad tiene estados discretos y transiciones definidas entre ellos.

Por que es importante: Hace explicito que transiciones son validas. No puedes ir de Draft a Delivered directamente; hay un camino definido.

Ejemplo practico: Order tiene estados draft -> confirmed -> paid -> shipped -> delivered. Cada transicion es un evento especifico. Intentar saltar pasos genera error.


Encapsulacion

Definicion: Principio de ocultar detalles internos y exponer solo una interfaz publica controlada.

Por que es importante: Previene que codigo externo corrompa el estado. Si items fuera publico, alguien podria hacer order.items.push() sin validar.

Ejemplo practico: private _items: Map<> es privado. La unica forma de agregar items es order.addItem() que valida cantidad, duplicados, y estado.


Version del Agregado

Definicion: Contador que indica cuantos eventos ha procesado el agregado. Empieza en -1 (sin eventos) y aumenta con cada evento.

Por que es importante: Se usa para control de concurrencia optimista al guardar. El repositorio verifica que la version no cambio desde la lectura.

Ejemplo practico: Cargas pedido (version 5), agregas item (genera evento, version interna 6), guardas esperando version 5. Si alguien guardo primero, falla.


Clear Uncommitted Events

Definicion: Operacion que vacia la lista de eventos no comprometidos despues de guardarlos exitosamente.

Por que es importante: Despues de persistir eventos, el agregado debe “olvidar” esos eventos para no guardarlos de nuevo en la proxima operacion.

Ejemplo practico: order.addItem(); repository.save(order) guarda el evento. order.clearUncommittedEvents() limpia la lista. Ahora getUncommittedEvents() retorna vacio.


Transicion de Estado

Definicion: Cambio del estado de una entidad de un valor a otro, tipicamente disparado por un evento.

Por que es importante: En Event Sourcing, el estado cambia SOLO como resultado de eventos. Las transiciones deben ser explicitas y trazables.

Ejemplo practico: OrderConfirmed causa transicion de draft a confirmed. El evento es el registro permanente de que y cuando ocurrio la transicion.


Test de Comportamiento

Definicion: Tests que verifican que el sistema se comporta correctamente, no solo que produce cierto estado.

Por que es importante: En Event Sourcing, verificamos que se generan los eventos correctos, no solo que el estado final es correcto.

Ejemplo practico: expect(order.getUncommittedEvents()).toHaveLength(1) y expect(events[0].eventType).toBe('OrderConfirmed') verifican comportamiento, no solo expect(order.status).toBe('confirmed').


← Capítulo 8: Event Store PostgreSQL | Capítulo 10: Proyecciones en Tiempo Real →