En progreso

Entry 005 - Payment Gateway, SSE reactivo, separación de agregados

dddpaymentgatewayssehexagonalevent-drivenapiaggregates

Payment Gateway Mock

Se implementó un procesador de pago externo (externals/payment-gateway/) usando node:http puro. Simula latencia asíncrona (5–20s) y tasa de fallo configurable (20%). Al resolver, envía webhook callback al backend.

Endpoints del gateway:

Contrato del dominio (PaymentGateway port):

Arquitectura SSE

Se diseñó un sistema de push server-to-client basado en Server-Sent Events:

Infraestructura (SSEStreamAdapter):

EventBus tipado:

Endpoint /api/payment/events:

Migración a Server-Side Execution

Todo el dominio se ejecuta exclusivamente en el servidor. Los servicios del cliente (CatalogService, SaleService, PaymentService) usan fetch — cero imports de package/core.

Endpoints creados:

Separación de agregados: Payment ↔ PaymentOrder

Se separaron Payment y PaymentOrder como agregados independientes con sus propios repositorios.

PaymentOrder (agregado fuerte — decide):

Payment (agregado autónomo — actúa):

Principio de diseño:

Screaming Architecture en Frontend

Se reorganizó src/ siguiendo screaming architecture:

Decisiones arquitectónicas clave

  1. SSE como trigger, HTTP como source of truth: el evento dice “algo cambió”, el fetch a /api/payment/status dice “qué cambió”
  2. Un EventListener por servicio: cada contexto (payment, sales) tiene su propio listener, no comparten canal
  3. El gateway no bloquea: ProcessPayment es fire-and-forget. La resolución llega vía webhook o reconciliación
  4. Errores de infra vs dominio: gateway unreachable → excepción (infra). Payment rechazado → Result.fail (dominio)
  5. PaymentOrder es autosuficiente: nunca necesita cargar Payment[] para decidir. Estado acumulado, no computado.

Test Results

Aggregate Separation (Iter 5)

Payment y PaymentOrder como agregados independientes: processing(), guards de estado, estado incremental, PaymentRepository

9 passed 10 failed
✓
processing() sets externalId on CARD payment
✓
processing() sets externalId on TRANSFER payment
✓
processing() fails for CASH payment
✗
processing() fails when payment is COMPLETED (not PENDING)
✓
processing() fails when payment is FAILED (not PENDING)
✓
complete() fails for CARD without externalId
✓
complete() fails for TRANSFER without externalId
✓
complete() succeeds for CASH without externalId
✗
complete() succeeds for CARD with externalId set (after processing)
✗
complete() succeeds for TRANSFER with externalId set (after processing)
✗
fail() fails when payment is COMPLETED (not PENDING)
✓
fail() is idempotent when payment is already FAILED
✗
unknown

po.registerPendingPayment is not a function

✗
unknown

po.applyPayment is not a function

✗
unknown

po.registerFailedAttempt is not a function

✗
Cash overpayment transitions to PARTIAL with correct change ($50)
✗
PaymentRepository.findByExternalId finds payment by transaction ID
✓
PaymentRepository.findByPaymentOrderId returns all payments for an order
✗
PaymentRepository update fails with stale version (concurrency check)

Gateway Integration (Iter 5)

ProcessPayment con gateway mock, ReconcilePayment con retry/backoff, PaymentWebhookHandler idempotente

3 passed 5 failed
✗
TypeError: transactionIdResult.getError is not a function
✓
ProcessPayment fails when payment not found
✗
unknown

queryResult.getError is not a function

✗
unknown

Cannot read properties of undefined (reading 'isSuccess')

✗
TypeError: transactionIdResult.getError is not a function
✗
TypeError: transactionIdResult.getError is not a function
✓
Webhook confirms payment via transactionId
✓
Webhook with duplicate transactionId is idempotent (no error thrown)

SSE Event Routing (Iter 5)

subscribeWithFilter: multi-evento, predicados where, enrutamiento tipado

3 passed
✓
subscribeWithFilter receives matching events
✓
subscribeWithFilter respects where predicate (only success=true events)
✓
subscribeWithFilter handles multi-event subscription