Arquitectura de Software

Domain-Driven Design: Open Host Service, cómo diseñar interfaces de integración estables entre Bounded Contexts.

Diseña una interfaz común de Inventory para Sales, Shipping y Marketplace. Define disponibilidad y reservas con TypeScript y Express, valida entradas y errores y planifica pruebas y evolución sin exponer el modelo interno.

Cargando tus opciones de aprendizaje…

Tres consumidores, tres maneras de saltarse Inventory

Sales consulta una tabla para mostrar disponibilidad. Shipping importa un servicio interno para preparar expediciones. Marketplace pide un endpoint exclusivo que devuelve casi toda la entidad de inventario.

Cada integración parece rápida por separado. Después Inventory reorganiza sus ubicaciones y rompe las tres: sus decisiones internas se habían convertido en contratos involuntarios.

Un Open Host Service, u OHS, ofrece un protocolo de integración coherente para que varios consumidores utilicen capacidades del contexto sin depender de su estructura interna. No consiste en exponer todos los métodos existentes ni en publicar una API sin autenticación.

Vamos a diseñar esa oferta para Inventory mediante TypeScript, Node.js y Express. Trabajaremos con consulta de disponibilidad y solicitud de reserva. La lógica de almacenamiento quedará detrás de un puerto explícito: el artículo se concentra en la interfaz pública, sus garantías y su evolución.

Qué ofrece un Open Host Service

Evans propone OHS cuando un contexto recibe suficientes necesidades de integración como para que mantener una traducción particular para cada consumidor resulte costoso. El proveedor establece un protocolo de servicios que los consumidores pueden utilizar y mantiene su coherencia al ampliarlo. Las necesidades muy particulares pueden resolverse por separado en vez de complicar el protocolo común. DDD Reference: Open-host Service, página 35 del documento.

En nuestra propuesta:

Sales -----------+
Shipping --------+----> Inventory: interfaz de integración
Marketplace -----+        - consultar disponibilidad
                          - solicitar reserva
                                  |
                                  v
                         aplicación y modelo internos

Las líneas representan consumidores de una capacidad publicada. No describen acceso a repositorios ni tablas compartidas.

La propuesta no obliga a que todos usen todas las operaciones. Shipping puede necesitar después una capacidad de expedición distinta; no debe recibirla mediante un parámetro oculto de reserva.

«Open» no significa acceso anónimo

El protocolo está disponible para los consumidores autorizados que deben integrarse. Puede ser interno, exigir credenciales y restringir capacidades por identidad.

Sales puede consultar y reservar, mientras Marketplace solo consulta hasta obtener autorización específica. Una interfaz común no elimina esas diferencias de permisos.

Tampoco requiere un nuevo proceso. Un monolito modular puede ofrecer esta interfaz y conservar sus modelos internos aislados.

Una API REST puede ser OHS, pero no por tener endpoints

REST describe un estilo de interacción. OHS describe una decisión de integración: ofrecer una interfaz mantenida, consistente y utilizable por varios consumidores.

Un conjunto de endpoints que copia tablas o crece con rutas como /stock-for-sales y /stock-for-marketplace, cada una con significados diferentes, puede seguir manteniendo integraciones particulares.

En cambio, una API pequeña con capacidades estables, responsabilidades claras y evolución documentada puede cumplir el propósito del patrón.

No hacen falta muchos consumidores actuales para diseñar cuidadosamente una interfaz. Pero tampoco conviene construir una plataforma universal si solo existe una necesidad pequeña y bien acotada.

Empezar por la capacidad, no por la entidad

El diseño problemático permite que los consumidores hagan esto:

// Contraejemplo conceptual: expone la estructura interna.
interface InventoryRow {
  id: number;
  sku: string;
  warehouse_id: number;
  physical_units: number;
  blocked_units: number;
  internal_review_flag: boolean;
}

function publicResponse(row: InventoryRow) {
  return row;
}

Ahora Sales tiene que decidir si las unidades bloqueadas cuentan, Marketplace interpreta una bandera interna y Shipping depende del identificador del almacén.

La mejora empieza por preguntar qué puede prometer Inventory:

  • Una consulta informa unidades reservables en el momento de la lectura.
  • Una solicitud intenta reservar una cantidad para el consumidor autorizado.
  • La respuesta de reserva indica un resultado efectivo, no una copia de columnas.
  • La distribución entre almacenes pertenece al modelo interno.

El proveedor mantiene sus reglas. El consumidor recibe información con significado suficiente para tomar su siguiente decisión.

Contratos de entrada y salida

Consultar disponibilidad

GET /integration/v1/availability/BAG-URBAN
Accept: application/json

Respuesta 200:

{
  "sku": "BAG-URBAN",
  "reservableQuantity": 8
}

El SKU conocido sin unidades devuelve cero. Un SKU desconocido devuelve 404 con un código estable. La consulta no reserva ni garantiza que esas unidades sigan disponibles después.

No exponemos ubicaciones, nombres de tablas o reglas de bloqueo internas.

Solicitar una reserva

POST /integration/v1/reservations
Content-Type: application/json

{
  "requestId": "checkout-100",
  "sku": "BAG-URBAN",
  "quantity": 2
}

Respuesta 201 para una reserva nueva:

{
  "reservationId": "reservation-900",
  "sku": "BAG-URBAN",
  "quantity": 2,
  "status": "RESERVED"
}

La identidad del consumidor viene de autenticación, no del cuerpo. requestId identifica una solicitud lógica dentro de esa identidad.

Para el ejemplo acordamos que una repetición idéntica devuelve la misma reserva con 200. Reutilizar la referencia con otros datos devuelve 409. Esta garantía exige implementación atómica y persistente: añadir un campo al DTO no la proporciona.

Las reglas de liberación y vigencia de reservas deben definirse antes de usar la capacidad real. Aquí no implementamos el ciclo completo; la respuesta tampoco significa que se haya expedido o vendido el producto.

Un puerto interno para las operaciones

La interfaz pública delega en la aplicación de Inventory. No se entrega ese puerto como biblioteca a los consumidores externos.

En inventory-integration.ts definimos:

export interface AvailabilityDto {
  readonly sku: string;
  readonly reservableQuantity: number;
}

export interface ReservationDto {
  readonly reservationId: string;
  readonly sku: string;
  readonly quantity: number;
  readonly status: "RESERVED";
}

export interface InventoryOperations {
  availability(sku: string): Promise<AvailabilityDto>;

  reserve(input: {
    consumerId: string;
    requestId: string;
    sku: string;
    quantity: number;
  }): Promise<{
    created: boolean;
    reservation: ReservationDto;
  }>;
}

export class InventoryPublicError extends Error {
  constructor(
    readonly code:
      | "SKU_NOT_FOUND"
      | "INSUFFICIENT_STOCK"
      | "REQUEST_CONFLICT",
  ) {
    super(code);
  }
}

La implementación debe calcular y persistir la reserva de forma segura ante concurrencia. No puede comprobar existencias, esperar una operación asíncrona y descontar después sin protección.

También debe conservar el resultado asociado a consumidor y solicitud, y detectar discrepancias. Este artículo no incluye esa implementación de dominio o base de datos; el puerto hace visible el trabajo que falta.

Una interfaz Express con validación explícita

Los siguientes bloques completan el mismo archivo. Presuponen Express 5, sus tipos y compilación de TypeScript con interoperabilidad de módulos adecuada. No incluyen configuración del proyecto ni un servidor listo para producción.

Express permite montar rutas y middleware y procesar cuerpos JSON. El contenido recibido necesita validación propia; activar el parser no convierte el cuerpo en datos confiables. Documentación oficial de Express.

Primero declaramos la dependencia de seguridad:

import express, {
  type Request,
  type ErrorRequestHandler,
} from "express";

type Operation = "availability:read" | "reservations:create";

export type Authorize = (
  request: Request,
  operation: Operation,
) => Promise<string>;

export class AccessError extends Error {
  constructor(readonly status: 401 | 403) {
    super("Access denied");
  }
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null &&
    !Array.isArray(value);
}

function validReference(value: unknown): value is string {
  return typeof value === "string" &&
    /^[A-Za-z0-9_-]{1,64}$/.test(value);
}

Authorize es una dependencia obligatoria. Su implementación debe verificar credenciales y el permiso solicitado, y devolver una identidad estable obtenida de una fuente confiable. No sirve devolver directamente una cabecera enviada por el cliente.

Después construimos las rutas:

export function createInventoryIntegration(
  operations: InventoryOperations,
  authorize: Authorize,
) {
  const app = express();
  app.disable("x-powered-by");

  app.get("/integration/v1/availability/:sku", async (req, res) => {
    await authorize(req, "availability:read");

    const sku = req.params.sku;
    if (!validReference(sku)) {
      res.status(400).json({ error: { code: "INVALID_REQUEST" } });
      return;
    }

    const result = await operations.availability(sku);
    res.set("Cache-Control", "no-store").json({
      sku: result.sku,
      reservableQuantity: result.reservableQuantity,
    });
  });

  app.post(
    "/integration/v1/reservations",
    express.json({ limit: "8kb" }),
    async (req, res) => {
      const consumerId = await authorize(req, "reservations:create");
      const body: unknown = req.body;

      if (
        !isRecord(body) ||
        Object.keys(body).some(
          key => !["requestId", "sku", "quantity"].includes(key),
        ) ||
        !validReference(body.requestId) ||
        !validReference(body.sku) ||
        typeof body.quantity !== "number" ||
        !Number.isSafeInteger(body.quantity) ||
        body.quantity < 1 ||
        body.quantity > 1000
      ) {
        res.status(400).json({ error: { code: "INVALID_REQUEST" } });
        return;
      }

      const result = await operations.reserve({
        consumerId,
        requestId: body.requestId,
        sku: body.sku,
        quantity: body.quantity,
      });

      const reservation = result.reservation;
      res.status(result.created ? 201 : 200).json({
        reservationId: reservation.reservationId,
        sku: reservation.sku,
        quantity: reservation.quantity,
        status: reservation.status,
      });
    },
  );

  const errors: ErrorRequestHandler = (error: unknown, _req, res, _next) => {
    if (error instanceof AccessError) {
      res.status(error.status).json({
        error: {
          code: error.status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN",
        },
      });
      return;
    }

    if (error instanceof InventoryPublicError) {
      const status = error.code === "SKU_NOT_FOUND" ? 404 : 409;
      res.status(status).json({ error: { code: error.code } });
      return;
    }

    if (isRecord(error) && error.type === "entity.too.large") {
      res.status(413).json({ error: { code: "REQUEST_TOO_LARGE" } });
      return;
    }

    if (isRecord(error) && error.type === "entity.parse.failed") {
      res.status(400).json({ error: { code: "INVALID_JSON" } });
      return;
    }

    res.status(500).json({ error: { code: "INTERNAL_ERROR" } });
  };

  app.use(errors);
  return app;
}

El límite de mil unidades es una decisión pública de esta interfaz, no una regla universal. Si otro consumidor necesita cantidades mayores, hay que revisar el alcance en lugar de añadir una excepción silenciosa por su nombre.

La serialización enumera los campos públicos. No devuelve el resultado mediante un spread que pueda filtrar propiedades internas agregadas posteriormente.

Express 5 propaga rechazos de handlers asíncronos al manejo de errores. El ejemplo depende de ese comportamiento; no debe copiarse sin adaptación a un entorno que no lo soporte.

El parser se ejecuta antes de la autorización del POST, con tamaño limitado. Una composición de producción puede autenticar antes y añadir límites de tráfico, tiempos y auditoría. Aquí se omiten esas implementaciones; no debe publicarse la ruta con una autorización ficticia.

Errores que los consumidores puedan interpretar

El contrato debe distinguir problemas diferentes:

  • 400: petición inválida; el consumidor debe corregir datos.
  • 401 o 403: falta de autenticación o permiso.
  • 404 con SKU_NOT_FOUND: Inventory no reconoce el SKU.
  • 409 con INSUFFICIENT_STOCK: no se puede satisfacer esa reserva.
  • 409 con REQUEST_CONFLICT: referencia reutilizada con otra solicitud.
  • 413: cuerpo demasiado grande.
  • 500: error inesperado; no implica que una operación no haya ocurrido.

Los códigos del ejemplo son decisiones del protocolo. Los consumidores no deberían analizar textos de errores de base de datos.

Un timeout después de enviar una reserva puede dejar al consumidor sin conocer el resultado. No debe inventar que fracasó ni crear solicitudes nuevas indiscriminadamente. La repetición con la misma referencia solo es segura cuando la garantía acordada está implementada y verificada.

Una reserva tampoco confirma un pedido en Sales. Cada contexto mantiene su responsabilidad y HTTP no crea una transacción compartida.

Una prueba de contrato sobre la interfaz real

Una prueba del transporte puede iniciar la aplicación Express con operaciones controladas. Así verifica rutas, validación y forma de respuesta sin necesitar una base de datos.

Este ejemplo propuesto usa APIs integradas de Node.js, además de Express. Presupone el archivo anterior compilado a JavaScript ESM. No se ha ejecutado como parte del artículo.

import test from "node:test";
import assert from "node:assert/strict";
import { once } from "node:events";
import {
  createInventoryIntegration,
  type InventoryOperations,
} from "./inventory-integration.js";

test("expone la reserva sin filtrar datos internos", async () => {
  const operations: InventoryOperations = {
    async availability(sku) {
      return { sku, reservableQuantity: 8 };
    },
    async reserve(input) {
      assert.equal(input.consumerId, "sales-test");

      return {
        created: true,
        reservation: {
          reservationId: "reservation-900",
          sku: input.sku,
          quantity: input.quantity,
          status: "RESERVED",
        },
      };
    },
  };

  // Autorización ficticia exclusiva de esta prueba.
  const app = createInventoryIntegration(
    operations,
    async () => "sales-test",
  );
  const server = app.listen(0, "127.0.0.1");

  try {
    await once(server, "listening");
    const address = server.address();
    if (!address || typeof address === "string") {
      throw new Error("No se pudo obtener el puerto de pruebas");
    }

    const response = await fetch(
      "http://127.0.0.1:" + address.port +
        "/integration/v1/reservations",
      {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          requestId: "checkout-100",
          sku: "BAG-URBAN",
          quantity: 2,
        }),
        signal: AbortSignal.timeout(3000),
      },
    );

    assert.equal(response.status, 201);
    assert.deepEqual(await response.json(), {
      reservationId: "reservation-900",
      sku: "BAG-URBAN",
      quantity: 2,
      status: "RESERVED",
    });
  } finally {
    await new Promise<void>((resolve, reject) => {
      server.close(error => error ? reject(error) : resolve());
    });
  }
});

Es una prueba de la interfaz HTTP del proveedor con su aplicación de negocio sustituida. No demuestra atomicidad de reservas, persistencia o autenticación real.

Para completar la verificación faltan casos de permisos, datos inválidos, SKU desconocido, conflicto de existencias y repetición de solicitudes. La implementación real de Inventory necesita pruebas concurrentes y durables para la garantía de reserva.

Además, los consumidores deben comprobar sus expectativas frente a una versión del proveedor en un entorno aislado. Un test que construye una respuesta ideal no prueba por sí solo compatibilidad entre sistemas.

Compatibilidad y evolución del protocolo

Supongamos que Inventory añade observedAt a la disponibilidad. Puede ser compatible si los consumidores toleran propiedades nuevas y el significado anterior se conserva.

No basta con decir «solo agregamos un campo»: hay que verificar cómo deserializan Sales, Shipping y Marketplace.

En cambio, retirar reservableQuantity, convertirlo en una cadena o empezar a incluir unidades bloqueadas cambia lo que los consumidores pueden esperar.

Una evolución responsable puede:

  1. Identificar consumidores y capacidades afectadas.
  2. Documentar el cambio y ejemplos.
  3. Mantener temporalmente la representación anterior cuando sea viable.
  4. Verificar las combinaciones de versiones.
  5. Acordar la migración y comprobarla antes de retirar soporte.

Una ruta v2 distingue protocolos, pero no explica su semántica ni resuelve su adopción.

Si Marketplace pide una puntuación comercial que combina disponibilidad con ventas históricas, no tiene por qué convertirse en un campo del protocolo de Inventory. Puede requerir una composición externa o una integración específica, manteniendo pequeño el servicio común.

OHS y los patrones que lo acompañan

Published Language aporta el lenguaje documentado de intercambio: qué significa reservar, cómo se expresan cantidades y qué representa cada respuesta. OHS es la oferta de capacidades y protocolo; ambos pueden complementarse.

Customer/Supplier describe cómo los equipos negocian necesidades. Inventory puede ofrecer OHS y mantener acuerdos concretos con algunos consumidores sin darles acceso al modelo interno.

Anti-Corruption Layer protege al consumidor. Sales puede traducir la interfaz pública de Inventory a su lenguaje propio aunque el proveedor ya tenga un protocolo consistente.

Servicio de dominio expresa comportamiento del modelo. OHS no es otro nombre para esa clase; es una interfaz de integración que puede invocar casos de uso y preservar sus reglas.

OHS tampoco es una interfaz genérica de CRUD sobre repositorios. Ofrecer capacidades no obliga a ofrecer escritura arbitraria sobre cualquier entidad.

El protocolo puede usar otros mecanismos

HTTP es una opción para consultas y solicitudes. También pueden existir protocolos de mensajería, RPC o intercambio de documentos.

La decisión importante es qué capacidad se ofrece y cómo los consumidores conocen sus resultados y garantías. Un conjunto de eventos internos publicados sin contrato no se vuelve OHS automáticamente.

Si una reserva se solicita de manera asíncrona, hay que distinguir recepción de solicitud de reserva efectiva. Un mensaje aceptado no demuestra que haya existencias reservadas.

No necesitamos incorporar un broker para aprender el patrón. El diseño del protocolo precede a esa decisión técnica.

Dos situaciones adicionales para evaluar el alcance

Educación: consultar plazas para varias aplicaciones

Un contexto académico ofrece disponibilidad de plazas a matrícula, un portal público y una herramienta de orientación. Una interfaz común puede evitar que cada consumidor reconstruya las reglas consultando tablas.

El portal público solo debería recibir la información autorizada para ese alcance. Compartir protocolo no significa exponer expedientes o datos personales a todos.

La lección es diseñar capacidades comunes con permisos y representaciones adecuadas.

Logística: evitar un protocolo universal prematuro

Un almacén necesita enviar un único informe diario a un sistema específico. Un intercambio acotado puede ser suficiente si el volumen y la necesidad lo justifican.

Crear desde el principio una API universal con decenas de operaciones, versiones y extensiones podría costar más que mantener esa integración concreta.

OHS aporta valor cuando hay una oferta de integración que merece mantenerse común. No es obligatorio convertir cualquier punto de contacto en una plataforma.

Orientar la IA hacia una interfaz coherente

Para Cursor, Claude o Codex, adjunta capacidades, contratos y límites del modelo interno.

Para diseñar:

Inventory atiende Sales, Shipping y Marketplace. Propón una interfaz común para disponibilidad y reservas. Distingue necesidades compartidas de excepciones particulares. No expongas entidades, tablas ni métodos internos. Explica garantías y permisos por capacidad.

Para implementar:

Implementa rutas Express para estos contratos, delegando en puertos de aplicación. Valida datos desconocidos, limita el cuerpo y serializa campos explícitos. La identidad del consumidor debe proceder de autorización verificada. No implementes una falsa garantía de reserva con un Map en memoria.

Para revisar:

Revisa este cambio del protocolo frente a los consumidores conocidos. Busca cambios de significado, filtración de propiedades internas y ampliaciones que obligan a actualizar consumidores. Distingue lo que cubren pruebas HTTP de lo que necesita verificación de persistencia y concurrencia.

Una persona debe comprobar el significado de las capacidades y las garantías efectivas de su implementación. Un endpoint que responde 201 no demuestra por sí solo que haya protegido existencias.

Ejercicios

1. El campo interno conveniente

Sales pide incluir warehouse_id porque así puede decidir dónde reservar. Inventory ya ofrece la capacidad de reserva. ¿Qué revisarías?

2. El consumidor especial

Marketplace necesita un cálculo comercial que ningún otro consumidor usa. ¿Debe agregarse siempre al contrato común?

3. La reserva que no es durable

La ruta devuelve 201 y guarda una referencia en memoria. Tras un reinicio, la misma solicitud crea otra reserva. ¿Qué garantía falta?

Soluciones razonadas

1. Revisaría si Sales tiene una necesidad real de elegir ubicación o si está asumiendo una responsabilidad interna de Inventory. Si existe una selección permitida, hay que modelarla como capacidad pública explícita, no filtrar una clave de persistencia.

2. No. Hay que evaluar si pertenece a Inventory y si tiene un significado compartido. Una composición o integración específica puede resolver la excepción sin convertir el protocolo común en una colección de particularidades.

3. Falta persistir y aplicar atómicamente la identidad de solicitud y su resultado, dentro del alcance autorizado. El contrato de repetición no se cumple por aceptar un requestId. Además hay que verificar que el descuento o bloqueo de unidades mantiene sus reglas ante concurrencia.

Una oferta estable sin congelar el dominio

Un Open Host Service resulta útil cuando puedes ofrecer capacidades coherentes a varios consumidores y sostener su contrato a lo largo del tiempo.

Comprueba que las operaciones hablan de necesidades públicas, que los datos internos permanecen privados y que cada garantía tiene una implementación y una prueba apropiadas.

El modelo puede evolucionar por dentro. La estabilidad consiste en administrar lo que otros contextos pueden esperar, no en impedir cualquier cambio.

Cargando tus opciones de aprendizaje…

Cargando conversación…

Sigue aprendiendo