Capa de interoperabilidad · Prueba técnica de arquitectura

4. Diseño de la capa de interoperabilidad

Conclusión. La capa se organiza por quién es dueño de cada dato. Cinco servicios de dominio cubren las preguntas que hoy nadie responde bien (¿a cuánto?, ¿cuánto puedo prometer?, ¿en qué va la orden?, ¿se cobró?, ¿a quién se le avisó?) y cuatro adaptadores aíslan a los sistemas existentes como capa anticorrupción. Los contratos son la fuente de verdad: OpenAPI para las APIs, AsyncAPI para los eventos y Protocol Buffers para gRPC. Crear orden tiene dos barreras de idempotencia y un modelo de errores único.

4.1 Bounded contexts

Un bounded context es la frontera dentro de la cual un modelo y su vocabulario significan una sola cosa; aquí define qué servicio es dueño de qué.

Contexto Pregunta que responde Dueño del dato Servicio de la capa
Catálogo y precios ¿Qué se vende y a cuánto, para este canal y cliente? ERP pricing-service (modelo de lectura)
Disponibilidad ¿Cuánto puedo prometer? WMS (físico), capa (reservas) inventory-service
Órdenes ¿En qué va esta orden? OMS (registro), capa (saga) order-service
Pagos ¿Se cobró? Pasarela payment-service
Despacho ¿Salió, llegó? WMS Sin servicio propio: eventos del adaptador WMS
Clientes ¿Quién es y qué segmento tiene? CRM Sin servicio propio en el MVP: adaptador CRM
Notificaciones a partners ¿A quién se le avisa y se le avisó? Capa webhook-service
Identidad y acceso de partners ¿Quién llama y qué puede hacer? Capa Keycloak

4.2 Servicios y responsabilidades

Servicio Expone Consume y publica Guarda
pricing-service GET /v1/prices Consume productos y precios del ERP y el segmento del cliente; entrega priceVersion, que la orden usa para validar el precio Productos, precios, segmentos por customerId opaco, eventos procesados
inventory-service GET /v1/stock; gRPC interno Reserve, Confirm, Release, GetReservation Consume stock físico del WMS y despachos; publica eventos de reserva Niveles de stock, reservas, outbox, eventos procesados
order-service POST /v1/orders, GET /v1/orders/{orderId} Orquesta la saga; publica eventos de orden y comandos a pagos y OMS Órdenes, pasos de la saga, llaves de idempotencia, outbox
payment-service Sin API para partners; recibe los webhooks de la pasarela en una ruta dedicada Consume comandos de cobro; publica resultados de pago Referencia de la pasarela, estado, monto y moneda. Nunca datos de tarjeta
webhook-service POST/GET/DELETE /v1/webhooks/subscriptions Consume eventos de órdenes, pagos y despacho; entrega a cada partner con firma Suscripciones, entregas e intentos

Comunicación: del gateway a los servicios, REST; de order-service a inventory-service, gRPC; todo lo demás, Kafka. No hay llamadas REST entre servicios de dominio. Una base de datos por servicio, sin tablas compartidas.

Lo que deliberadamente no se construye, y por qué:

4.3 Patrón de integración: adaptadores como capa anticorrupción

Cada sistema existente queda detrás de un adaptador que actúa como ACL (anti-corruption layer, capa anticorrupción): traduce entre el modelo canónico y el del sistema, de modo que el modelo del legado no se filtre hacia adentro.

Adaptador Del sistema hacia la capa De la capa hacia el sistema
erp-adapter CDC (change data capture: leer los cambios desde el log de la base de datos, sin consultar al sistema) sobre la base del ERP: productos y precios Ninguna en el MVP
wms-adapter Sondeo incremental de niveles de stock y de estados de despacho: publica DispatchStarted y DeliveryConfirmed por outbox, y de ahí salen el despacho y la entrega que ve el partner Ninguna en el MVP (el OMS sigue enviando el alistamiento al WMS)
oms-adapter Confirmación de orden registrada Crear orden confirmada, cancelar orden
crm-adapter Segmento del cliente (después del MVP) Notificar compra para puntos (después del MVP)

Reglas comunes a todo adaptador:

  1. Es el único componente con credenciales y ruta de red hacia su sistema.
  2. Limita su concurrencia y su tasa de llamadas, con circuit breaker, timeout y reintentos solo en operaciones idempotentes (sección 6.3).
  3. Publica en dos pasos: tópico crudo (raw.<sistema>.*) y tópico canónico. El crudo permite reprocesar si cambia la traducción.
  4. No guarda estado de negocio; solo cursores de lectura y tabla de deduplicación.
  5. Agregar un sistema nuevo es crear un adaptador desde la plantilla; los servicios de dominio no cambian.

Trade-off: un adaptador por sistema es más código que un integrador central, y una traducción que cambia se toca en un solo lugar pero se debe desplegar. Se acepta porque el costo de un cambio en el legado queda confinado a una pieza.

Modelo canónico, el vocabulario único dentro de la capa: sku; locationId (tienda o bodega); channel; partnerId; customerId opaco, nunca nombre ni documento en eventos; orderId con prefijo ord_; dinero como decimal en texto más moneda ISO 4217 (el estándar de códigos de moneda, por ejemplo COP), nunca punto flotante; tiempos en ISO 8601 (el estándar de fechas) en UTC (tiempo universal coordinado).

Eventos: todos usan el sobre CloudEvents 1.0, una especificación abierta que estandariza los metadatos de un evento (id, source, type, subject, time) más tres extensiones propias: correlationid, traceparent y partnerid. La clave de partición es el agregado (orderId o sku), así que los eventos de una orden llegan en orden. Entrega al menos una vez y deduplicación por id en cada consumidor. Los eventos no llevan datos personales: si un consumidor necesita la dirección, la pide al dueño con el orderId. El catálogo de tópicos está en el anexo C.

4.4 Flujo A: crear orden, antes y después

En el AS-IS no hay saga: hay una cadena de llamadas síncronas dirigida desde el canal, sin compensaciones. El stock baja en el alistamiento (paso 7), no al vender; si se pierde el webhook de pago la orden queda pagada y pendiente; si el canal reintenta, se duplica la orden. Y cada canal tiene su propia versión de la cadena: eso explica la fragilidad mejor que la cantidad de conexiones.

Flujo A en el AS-IS

En el TO-BE la saga es orquestada por order-service: un solo lugar conoce los pasos, el estado y las compensaciones.

Flujo A en el TO-BE

# Paso Modo Tipo Si falla
1 Validar token, scope y canal Sync Validación 401 o 403
2 Idempotencia Sync Validación Devuelve la respuesta original, o 409 o 422
3 Validar catálogo y precio contra el modelo de lectura Sync Validación 422 UNKNOWN_SKU o PRICE_MISMATCH
4 Reservar stock Sync, gRPC Compensable (liberar) 409 INSUFFICIENT_STOCK
5 Guardar orden y responder 202 Sync Local La reserva vence sola
6 Cobrar Async Pivote Liberar reserva, cancelar orden
7 Confirmar reserva Async Reintentable Reintento; si la reserva venció y no hay stock, reembolso
8 Registrar en OMS Async Reintentable Reintento con espera creciente y alerta; no se compensa
9 Notificar al partner Async Reintentable Reintentos y cola de fallidos

El cobro es el pivote: antes de él todo se puede deshacer; después de él todo se reintenta hasta lograrlo, porque el cliente ya pagó. Si el pago se rechaza, order-service libera la reserva, marca la orden CANCELLED y el partner recibe payment.rejected y order.cancelled. Si la respuesta del cobro se pierde, payment-service no lo da por no cobrado: lo busca en la pasarela por los metadatos de la orden antes de anularlo, y si aparece aprobado después de anular, lo reembolsa. Una tarjeta rechazada sale como cobro fallido con su referencia.

Estados de la orden

El canal ve PENDING_PAYMENT, CONFIRMED, CANCELLED, DISPATCHED y DELIVERED; RECEIVED y REJECTED son internos. El registro en el OMS se sigue aparte con el campo omsSync.

Hueco conocido del demo: el OMS necesita la dirección de entrega para despachar, y esa dirección vive solo en order-service, cifrada, sin salir a Kafka (sección 5.5). Hoy la orden se registra en el OMS sin dirección. La salida es el ADR-025 (anexo D), aceptado el 15/11/2026 y sin implementar: oms-adapter pide la dirección a order-service por gRPC con el orderId, como un ticket de reclamo, sin datos personales en el tópico. Costará una segunda llamada síncrona interna, es decir, una excepción explícita a la regla de la sección 3.3. Hasta que se implemente, la orden sale al OMS sin dirección de entrega.

Se descartó la coreografía (cada servicio reacciona a eventos sin coordinador) porque con cinco participantes nadie puede responder en qué va una orden, que es justo el problema del AS-IS. Se descartó un motor de flujos (Temporal o Step Functions) porque es otra pieza que operar para un flujo de nueve pasos. Trade-off: order-service conoce a los demás participantes y es el punto de mayor acoplamiento lógico, y el manejo de estado y recuperación es código propio. Se revisaría al sumar la tercera saga (devoluciones, cancelaciones), o si el supuesto S1 resulta falso.

Stock vendible y reservas. inventory-service calcula el stock vendible, lo que la industria llama ATP (available to promise: lo que se puede prometer sin riesgo de incumplir): físico según el WMS, menos lo reservado por la capa, menos un colchón de seguridad. La reserva es una actualización condicional atómica en PostgreSQL:

UPDATE stock_level
   SET reserved = reserved + :qty, version = version + 1
 WHERE sku = :sku AND location_id = :loc
   AND on_hand - reserved - safety_buffer >= :qty;

Si no actualiza fila, no hay stock y la orden se rechaza con 409. Una orden con varias líneas reserva todas en una transacción, ordenando las filas por SKU para evitar interbloqueos. Redis no participa en esta decisión. La reserva vence a los 15 minutos (configurable por canal), lo que también es la red de seguridad de la saga: si order-service falla después de reservar, la reserva se libera sola.

Estados de la reserva

Límites honestos: la garantía es total entre canales que pasan por la capa y parcial frente a ventas que no pasan por ella (tiendas físicas, canales aún no migrados). Se cubre con el colchón de seguridad por SKU y canal, la frescura del WMS medida y con alerta, y la conciliación nocturna. Despacho: el físico y la reserva bajan por tópicos distintos. El descuento del físico (feed de stock del WMS, StockLevelChanged en andes.inventory.stock-levels.v1) y el consumo de la reserva (DispatchStarted en andes.fulfillment.events.v1) no tienen orden entre sí. Solo un orden es peligroso: si el despacho llega primero, el vendible sube por unos segundos lo despachado y se puede prometer una unidad que ya salió. Si el físico llega primero, el vendible baja de más por unos segundos, que es el lado seguro. Duración de la ventana: por medir (make f4-e2e comprueba que el vendible vuelve a su valor en menos de 30 s, no mide la ventana). En el MVP lo cubre el colchón de seguridad. Salida para producción, sin implementar: que el consumo de la reserva espere a que el nivel de stock de ese SKU y ubicación tenga wmsAsOf igual o posterior al occurredAt del despacho. Trade-off: la capa pasa a tener un dato de negocio propio, la única excepción al principio de no poseer datos. Se justifica porque no existe dueño hoy y porque reservar en el WMS metería al legado on-prem en la ruta síncrona de crear orden.

4.5 Contrato 1: consultar stock

GET /v1/stock, scope stock:read. Acepta hasta 50 SKU por llamada. El canal sale del token; el parámetro channel solo se usa si el partner tiene varios, y debe ser uno de los suyos.

GET /v1/stock?sku=SKU-1001&sku=SKU-2002&storeId=BOG-01 HTTP/1.1
Host: api.andesretail.com
Authorization: Bearer eyJhbGciOi...
X-Correlation-Id: 7f3c2a9e-51d4-4f0b-9a57-2c1e8b6d4a10
HTTP/1.1 200 OK
Content-Type: application/json
X-Correlation-Id: 7f3c2a9e-51d4-4f0b-9a57-2c1e8b6d4a10
RateLimit-Limit: 500
RateLimit-Remaining: 97
Cache-Control: private, max-age=2

{
  "items": [
    {
      "sku": "SKU-1001",
      "storeId": "BOG-01",
      "channel": "mkp-andino",
      "available": 42,
      "status": "IN_STOCK",
      "asOf": "2026-11-10T18:04:11Z",
      "stale": false
    },
    {
      "sku": "SKU-2002",
      "storeId": "BOG-01",
      "channel": "mkp-andino",
      "available": 0,
      "status": "OUT_OF_STOCK",
      "asOf": "2026-11-10T18:04:09Z",
      "stale": false
    }
  ],
  "notFound": []
}

available ya descuenta reservas, colchón y el tope visible del canal: no se exponen el físico ni lo reservado. El mensaje del 409 INSUFFICIENT_STOCK de crear orden ("Disponible: N") aplica el mismo tope visible que la consulta de stock (corregido el 15/11/2026). asOf dice de cuándo es el dato y stale avisa si superó el umbral de desactualización (sección 6.2).

4.6 Contrato 2: crear orden

POST /v1/orders, scope orders:write. La cabecera Idempotency-Key es obligatoria y es un UUID (identificador único universal) que genera el cliente.

POST /v1/orders HTTP/1.1
Host: api.andesretail.com
Authorization: Bearer eyJhbGciOi...
Idempotency-Key: 0b8f6f0e-6a1b-4d5c-9c59-0d6f3f0a2b77
Content-Type: application/json

{
  "externalOrderId": "MKP-778899",
  "customer": {
    "customerId": "cus_8f3a21",
    "name": "Laura Gómez",
    "email": "laura@example.com",
    "phone": "+573001234567"
  },
  "fulfillment": {
    "type": "DELIVERY",
    "storeId": "BOG-01",
    "address": {
      "line1": "Calle 10 # 5-20",
      "city": "Bogotá",
      "region": "DC",
      "country": "CO"
    }
  },
  "lines": [
    {
      "sku": "SKU-1001",
      "quantity": 2,
      "unitPrice": "159900.00",
      "priceVersion": "pv_01JC8Z4T6M"
    }
  ],
  "payment": {
    "mode": "CAPTURE",
    "method": "CARD",
    "token": "tok_4f9a7c2e",
    "amount": "319800.00",
    "currency": "COP"
  }
}
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /v1/orders/ord_01JC8ZB7Q2X9
X-Correlation-Id: 5e1d0c7a-93b2-4c41-8d0e-6a7f2b9c3d15

{
  "orderId": "ord_01JC8ZB7Q2X9",
  "externalOrderId": "MKP-778899",
  "channel": "mkp-andino",
  "status": "PENDING_PAYMENT",
  "reservation": { "expiresAt": "2026-11-10T18:19:30Z" },
  "total": { "amount": "319800.00", "currency": "COP" },
  "createdAt": "2026-11-10T18:04:30Z",
  "updatedAt": "2026-11-10T18:04:30Z",
  "links": { "self": "/v1/orders/ord_01JC8ZB7Q2X9" }
}

202 significa: la orden está aceptada y el stock reservado; el cobro sigue en curso. El resultado llega por webhook y se consulta en links.self. payment.token es el token que entrega la pasarela. Los identificadores (externalOrderId, channel, customer.customerId, fulfillment.storeId, lines[].sku y lines[].priceVersion) tienen formato declarado en el contrato: de 1 a 64 caracteres, letras ASCII, dígitos y ., _, : y -, empezando por letra o dígito (supuesto S27); uno fuera del formato recibe 400 VALIDATION_ERROR con el campo en errors[].field. El texto libre (nombre, correo, dirección) y payment.token pasan por un detector que rechaza un número de tarjeta enviado por error: 13 a 19 dígitos que cierren con el dígito de verificación de las tarjetas (algoritmo de Luhn). Los identificadores quedan fuera del detector a propósito (sección 5.5, ADR-026). La capa es la autoridad del precio: acepta unitPrice si coincide con el vigente o con uno reemplazado dentro de la ventana de pago del canal, que es la misma duración de la reserva (15 minutos por defecto). La ventana es una política comercial: protege al cliente que vio un precio y está pagando. Un cambio marcado como corrección en el ERP invalida de inmediato las versiones anteriores, para no seguir vendiendo un precio mal cargado. order-service valida contra su propia vista de precios, alimentada por el tópico de precios (ADR-015). Si no, responde 422 PRICE_MISMATCH con el precio vigente.

4.7 Modelo de errores

Un solo formato para toda la API: Problem Details (RFC 9457; un RFC, Request for Comments, es un documento de estándar del IETF, el organismo que publica los estándares de internet, para describir errores HTTP), con tipo de contenido application/problem+json y tres campos propios: code, correlationId y errors.

{
  "type": "https://api.andesretail.com/problems/insufficient-stock",
  "title": "Stock insuficiente",
  "status": 409,
  "code": "INSUFFICIENT_STOCK",
  "detail": "No hay unidades suficientes para una o más líneas.",
  "instance": "/v1/orders",
  "correlationId": "5e1d0c7a-93b2-4c41-8d0e-6a7f2b9c3d15",
  "errors": [
    { "field": "lines[0].quantity", "code": "INSUFFICIENT_STOCK", "message": "Disponible: 1" }
  ]
}
HTTP code Cuándo ¿Reintentar?
400 VALIDATION_ERROR El cuerpo o los parámetros no cumplen el contrato No
401 UNAUTHENTICATED Token ausente, vencido o inválido Con token nuevo
403 INSUFFICIENT_SCOPE Falta el scope de la ruta No
403 CHANNEL_NOT_ALLOWED Canal, tienda o modo de cobro no permitidos al partner No
404 NOT_FOUND El recurso no existe o es de otro partner No
409 INSUFFICIENT_STOCK No se pudo reservar No, o con otra cantidad
409 REQUEST_IN_PROGRESS Misma llave de idempotencia aún en proceso Sí, tras Retry-After
413 PAYLOAD_TOO_LARGE El cuerpo supera el tope de la ruta; lo responde el gateway de entrada en las operaciones con cuerpo No, con un cuerpo más pequeño
422 UNKNOWN_SKU SKU inexistente o no habilitado para el canal No
422 PRICE_MISMATCH El precio enviado no coincide con el vigente Con el precio vigente
422 IDEMPOTENCY_KEY_REUSED Misma llave con cuerpo distinto No
429 RATE_LIMITED Cuota del partner agotada Sí, tras Retry-After
500 INTERNAL_ERROR Falla no prevista de la capa; también cualquier otro 5xx que el borde normaliza Sí, con la misma llave de idempotencia
503 DEPENDENCY_UNAVAILABLE La capa no puede responder con seguridad Sí, tras Retry-After

Reglas: un recurso de otro partner responde 404, no 403, para no revelar que existe. Los errores 5xx nunca incluyen detalles internos. El correlationId permite a soporte encontrar la traza completa. Trade-off frente a un formato propio: el estándar obliga a convivir con sus campos, pero los partners ya tienen herramientas para él.

4.8 Idempotencia en crear orden

Dos barreras, ambas con restricción única en la base:

  1. Idempotency-Key: clave partnerId más la llave; se guarda con el hash del cuerpo y la respuesta, por 24 horas.
  2. Llave natural: partnerId más externalOrderId.
Situación Respuesta
Misma llave, mismo cuerpo, petición ya terminada La respuesta original, con Idempotent-Replayed: true
Misma llave, mismo cuerpo, petición en curso 409 REQUEST_IN_PROGRESS
Misma llave, cuerpo distinto 422 IDEMPOTENCY_KEY_REUSED
Llave nueva, externalOrderId ya existente 200 con la orden existente
Llave nueva, externalOrderId de una orden que otra llave todavía crea 409 REQUEST_IN_PROGRESS, sin guardar la respuesta
Cuerpo o canal inválido (400 VALIDATION_ERROR, 403 CHANNEL_NOT_ALLOWED) No consume la llave: se corrige el cuerpo y se reintenta con la misma llave

Hacia adentro cada paso es idempotente por orderId: reservar, confirmar, liberar, cobrar (la llave de idempotencia de la pasarela es el orderId) y registrar en OMS. El cambio de estado y su evento se guardan en la misma transacción (outbox: un proceso aparte lee la tabla y publica en Kafka), así que no existe orden guardada sin evento ni evento sin orden; cada consumidor deduplica por el id del evento. Trade-off: guardar respuestas y llaves cuesta almacenamiento y una escritura más por orden, a cambio de que un reintento del canal nunca cree una segunda orden ni un segundo cobro.