Arquitectura de Software

Domain-Driven Design: Published Language, cómo establecer contratos y un lenguaje común entre Bounded Contexts.

Diseña PaymentConfirmedV1 para Sales, Billing y Analytics. Define identidades, unidades y significado, valida con Zod y JSON Schema y aprende a evolucionar contratos sin filtrar el modelo interno ni cambiar sus promesas.

Cargando tus opciones de aprendizaje…

Un JSON válido que tres equipos entienden distinto

Payments comunica que un pago está confirmado. Sales interpreta que puede continuar la venta. Billing entiende que debe emitir una factura. Analytics registra el importe como ingreso neto.

Los tres consumidores reciben el mismo documento, pero están suponiendo cosas diferentes: ¿confirmado significa autorizado o capturado?, ¿el importe incluye comisiones?, ¿la fecha corresponde al pago o al envío del mensaje?

El problema no se resuelve eligiendo nombres de campos más bonitos. Necesitamos un lenguaje de intercambio publicado, con estructura y significado explícitos.

Construiremos un contrato llamado PaymentConfirmedV1. Definiremos qué afirma y qué no afirma, lo validaremos con Zod y lo representaremos conceptualmente en JSON Schema. No implementaremos transporte ni infraestructura de mensajería.

Qué es un Published Language

Un Published Language, o lenguaje publicado, es una representación de intercambio documentada que permite a distintos contextos interpretar la información de manera consistente.

Evans lo presenta como un lenguaje común de intercambio para la comunicación entre modelos. Puede aprovechar un estándar existente o una especificación acordada para el problema concreto. DDD Reference: Published Language, página 36 del documento.

«Publicado» no significa disponible para cualquier persona en Internet. Significa accesible y comprensible para los participantes autorizados de la integración.

Tampoco exige una clase compartida. Un consumidor en TypeScript y otro en otro lenguaje pueden interpretar el mismo contrato sin compartir implementación.

Lo que debe quedar publicado

Para nuestro caso necesitamos conocer:

  • Qué acontecimiento representa el mensaje.
  • Qué identifica cada referencia.
  • Cómo se expresan importe, moneda y fecha.
  • Qué estados y valores son válidos.
  • Qué propiedades se requieren y cómo se admiten ampliaciones.
  • Qué versión se está utilizando.
  • Qué hechos no pueden deducirse del mensaje.

Un esquema automático verifica parte de eso. La semántica necesita además definiciones y ejemplos que las personas puedan revisar.

Separar el lenguaje interno del intercambio

Payments puede tener un modelo interno con autorizaciones, capturas, intentos, devoluciones y referencias de distintas pasarelas.

Sales no necesita recibir ese modelo entero. Billing tampoco necesita credenciales o respuestas técnicas del proveedor financiero.

Modelo interno de Payments
           |
           | selección y traducción explícitas
           v
PaymentConfirmedV1 + reglas publicadas
           |
           +----> Sales: interpreta para su proceso comercial
           +----> Billing: interpreta para su proceso documental
           +----> Analytics: interpreta para sus métricas

Las líneas muestran intercambio de información. No significan una transacción común ni la obligación de utilizar un broker.

Un evento interno del dominio puede motivar la creación del mensaje, pero no tiene por qué ser su contrato público. Cambiar un detalle interno no debería obligar automáticamente a todos los consumidores a cambiar.

Definir primero qué significa «confirmado»

Para esta tienda hipotética acordamos que PaymentConfirmedV1 afirma:

Payments registró una captura completa y exitosa para un pago y un pedido, por el importe y moneda indicados.

No representa una autorización pendiente de captura. No representa un saldo actual ni garantiza que nunca habrá devoluciones o disputas.

El contrato del ejemplo solo admite pagos positivos en EUR y una confirmación lógica por pago. Las capturas parciales quedan fuera de esta versión.

Estas decisiones son del ejemplo, no reglas universales de las pasarelas. Si el negocio necesita otra garantía, hay que diseñar otro acuerdo.

Diccionario mínimo

messageId: UUID que identifica este acontecimiento publicado. Reenviar la misma confirmación conserva esa identidad; generar una nueva por cada envío dificultaría reconocer el mismo hecho.

paymentId: identificador público del pago en Payments. No es el identificador de una transacción de la pasarela.

orderId: referencia pública del pedido al que Payments asocia el pago. No demuestra por sí sola que Sales ya haya confirmado ese pedido.

amountMinor: importe bruto capturado para este pago, antes de descontar comisiones de la pasarela. En EUR se expresa en céntimos: 7000 significa 70 euros. No es un cálculo de ingreso neto ni un desglose fiscal.

currency: moneda del importe. Esta versión acepta únicamente EUR. Extenderla exige acordar las unidades de cada moneda, no asumir que todas se dividen en cien.

status: el literal CONFIRMED. En este contrato es redundante con el tipo del mensaje, pero deja explícito el estado comunicado. No es una enumeración abierta donde se puedan añadir rechazos o devoluciones sin revisar el diseño.

occurredAt: instante en que Payments registró la confirmación de la captura, en UTC con tres dígitos de milisegundos y sufijo Z. No es la hora a la que un consumidor recibe el mensaje ni una fecha comercial local.

Una marca temporal tampoco ordena necesariamente todos los hechos de distintos sistemas. Los consumidores no deben inventar esa garantía.

El documento que intercambiamos

{
  "type": "payment.confirmed",
  "schemaVersion": 1,
  "messageId": "79a1db8e-4f56-4e69-972b-9ca7d06c1d18",
  "paymentId": "payment-100",
  "orderId": "order-100",
  "amountMinor": 7000,
  "currency": "EUR",
  "status": "CONFIRMED",
  "occurredAt": "2026-01-15T10:30:00.000Z"
}

El documento representa un hecho concreto y no una orden para todos los receptores.

Sales todavía debe comprobar su propio proceso. Billing debe aplicar sus reglas de emisión. Analytics debe distinguir importe bruto de ingreso neto.

El lenguaje común permite compartir la afirmación; no obliga a los consumidores a tomar la misma decisión.

Validación en ejecución con Zod

El siguiente archivo payment-confirmed-v1.ts utiliza las APIs de Zod 4. Presupone esa dependencia disponible en un proyecto TypeScript con Node.js; no incluye configuración ni instala paquetes.

import * as z from "zod";

const PublicReference = z.string()
  .min(1)
  .max(64)
  .regex(/^[A-Za-z0-9_-]+$/);

export const PaymentConfirmedV1Schema = z.object({
  type: z.literal("payment.confirmed"),
  schemaVersion: z.literal(1),
  messageId: z.uuid(),
  paymentId: PublicReference,
  orderId: PublicReference,
  amountMinor: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
  currency: z.literal("EUR"),
  status: z.literal("CONFIRMED"),
  occurredAt: z.iso.datetime({ precision: 3 }),
});

export type PaymentConfirmedV1 =
  z.infer<typeof PaymentConfirmedV1Schema>;

export function parsePaymentConfirmedV1(
  input: unknown,
): PaymentConfirmedV1 {
  return PaymentConfirmedV1Schema.parse(input);
}

El tipo inferido ayuda durante el desarrollo. El parser comprueba datos recibidos durante la ejecución. No utilizamos coerción para convertir silenciosamente "7000" en 7000.

La configuración de fecha exige milisegundos y utiliza el formato UTC admitido por defecto. z.object elimina propiedades desconocidas del resultado validado: el consumidor acepta ampliaciones, pero solo utiliza los campos que conoce. Zod: esquemas, fechas y objetos.

Esa política es una decisión de compatibilidad, no la única posible. Un consumidor que rechace cualquier propiedad nueva tendrá otras restricciones y debe documentarlas.

Validar la estructura no demuestra que hubo una captura real. La autenticidad del emisor, la autorización y las decisiones del modelo productor necesitan sus propios controles.

Publicar desde un modelo interno diferente

Para mostrar la separación, usamos una proyección interna didáctica, no un agregado completo ni una implementación financiera.

En publish-payment-confirmed.ts:

import {
  PaymentConfirmedV1Schema,
  type PaymentConfirmedV1,
} from "./payment-confirmed-v1.js";

interface InternalCaptureRecord {
  readonly paymentReference: string;
  readonly salesReference: string;
  readonly grossCapturedCents: number;
  readonly lifecycle: "CAPTURED" | "AUTHORIZED";
  readonly confirmationRegisteredAt: Date;
  readonly providerTransactionReference: string;
}

export function createPaymentConfirmedV1(
  record: InternalCaptureRecord,
  messageId: string,
): PaymentConfirmedV1 {
  if (record.lifecycle !== "CAPTURED") {
    throw new Error("La autorización no permite publicar confirmación");
  }

  return PaymentConfirmedV1Schema.parse({
    type: "payment.confirmed",
    schemaVersion: 1,
    messageId,
    paymentId: record.paymentReference,
    orderId: record.salesReference,
    amountMinor: record.grossCapturedCents,
    currency: "EUR",
    status: "CONFIRMED",
    occurredAt: record.confirmationRegisteredAt.toISOString(),
  });
}

export function serializePaymentConfirmedV1(
  message: PaymentConfirmedV1,
): string {
  return JSON.stringify(PaymentConfirmedV1Schema.parse(message));
}

No serializamos el objeto interno mediante un spread. La referencia privada de la pasarela queda fuera del contrato, aunque exista en el modelo productor.

El llamador debe proporcionar una identidad de mensaje asociada de forma estable al hecho. Estas funciones no implementan esa conservación ni el envío durable; tampoco aseguran que una publicación y un cambio de estado se registren juntos.

La condición CAPTURED ilustra una precondición, no sustituye las reglas que permiten llegar a ese estado.

Recibir sin importar el dominio de Payments

Un consumidor puede validar una cadena JSON en su borde:

import { parsePaymentConfirmedV1 } from "./payment-confirmed-v1.js";

export function readPaymentMessage(serialized: string) {
  const raw: unknown = JSON.parse(serialized);
  const message = parsePaymentConfirmedV1(raw);

  return {
    paymentReference: message.paymentId,
    orderReference: message.orderId,
    grossCapturedCents: message.amountMinor,
    confirmationTime: message.occurredAt,
  };
}

Este fragmento solo muestra recepción y selección de datos. No consulta pedidos ni actualiza su estado.

Los ejemplos reutilizan el esquema para abreviar el tutorial. En integraciones reales cada consumidor puede mantener su validador y traducir mediante su ACL, usando una especificación publicada común. No necesita importar entidades de Payments.

Tampoco debe confiar en un cuerpo arbitrario solo porque pase Zod. El canal debe garantizar que proviene de un emisor permitido, con límites de tamaño y tratamiento de errores apropiados.

Representar el contrato mediante JSON Schema

Un lenguaje publicado puede ofrecer artefactos legibles por herramientas. Este JSON Schema conceptual refleja la aceptación de propiedades adicionales y las restricciones principales de nuestra versión.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "PaymentConfirmedV1",
  "type": "object",
  "required": [
    "type",
    "schemaVersion",
    "messageId",
    "paymentId",
    "orderId",
    "amountMinor",
    "currency",
    "status",
    "occurredAt"
  ],
  "properties": {
    "type": { "const": "payment.confirmed" },
    "schemaVersion": { "const": 1 },
    "messageId": { "type": "string", "format": "uuid" },
    "paymentId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 64,
      "pattern": "^[A-Za-z0-9_-]+$"
    },
    "orderId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 64,
      "pattern": "^[A-Za-z0-9_-]+$"
    },
    "amountMinor": {
      "type": "integer",
      "minimum": 1,
      "maximum": 9007199254740991
    },
    "currency": { "const": "EUR" },
    "status": { "const": "CONFIRMED" },
    "occurredAt": {
      "type": "string",
      "format": "date-time",
      "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"
    }
  },
  "additionalProperties": true
}

Es una representación conceptual revisada manualmente, no una exportación automática de Zod ni una prueba de equivalencia entre validadores.

En JSON Schema, el tratamiento de format depende del validador y su configuración. Hay que comprobar que UUID y fecha se validan como se espera. El patrón de fecha fija la forma, pero no demuestra por sí solo que el día exista. JSON Schema: formatos de cadenas.

También hay una diferencia deliberada: JSON Schema describe si la entrada se acepta; el parser Zod además produce un objeto sin propiedades desconocidas.

No mantengas dos especificaciones divergentes a mano en producción. Define una fuente de verdad o una generación controlada, y verifica los mismos ejemplos válidos e inválidos con todos los artefactos publicados.

OpenAPI, AsyncAPI y el patrón conceptual

Si el documento forma parte de una API HTTP, OpenAPI puede describir su operación y esquema. Si se intercambia mediante mensajería, AsyncAPI puede describir canales, mensajes y sus contratos.

Estas herramientas ayudan a distribuir especificaciones. No deciden qué significa «confirmado» ni quién responde por su evolución.

Tener un archivo llamado openapi.yaml no demuestra que exista un lenguaje publicado coherente. Puede documentar perfectamente un campo llamado amount sin explicar su unidad o significado.

Para esta versión basta una especificación pequeña, ejemplos, un diccionario y validadores. No hace falta construir un catálogo universal de eventos.

Pruebas del contrato y sus límites

Estos ejemplos propuestos usan Zod 4 y las APIs integradas de Node.js. Presuponen compilación a JavaScript ESM. No se han ejecutado como parte del artículo.

En payment-confirmed-v1.test.ts:

import test from "node:test";
import assert from "node:assert/strict";
import {
  PaymentConfirmedV1Schema,
} from "./payment-confirmed-v1.js";

const validMessage = {
  type: "payment.confirmed",
  schemaVersion: 1,
  messageId: "79a1db8e-4f56-4e69-972b-9ca7d06c1d18",
  paymentId: "payment-100",
  orderId: "order-100",
  amountMinor: 7000,
  currency: "EUR",
  status: "CONFIRMED",
  occurredAt: "2026-01-15T10:30:00.000Z",
};

test("conserva el importe y la unidad acordados", () => {
  const parsed = PaymentConfirmedV1Schema.parse(validMessage);
  assert.equal(parsed.amountMinor, 7000);
  assert.equal(parsed.currency, "EUR");
});

test("rechaza importes como texto y valores fraccionarios", () => {
  assert.equal(PaymentConfirmedV1Schema.safeParse({
    ...validMessage,
    amountMinor: "7000",
  }).success, false);

  assert.equal(PaymentConfirmedV1Schema.safeParse({
    ...validMessage,
    amountMinor: 70.5,
  }).success, false);
});

test("una autorización no es una confirmación", () => {
  assert.equal(PaymentConfirmedV1Schema.safeParse({
    ...validMessage,
    status: "AUTHORIZED",
  }).success, false);
});

test("acepta una propiedad adicional sin utilizarla", () => {
  const parsed = PaymentConfirmedV1Schema.parse({
    ...validMessage,
    sourceChannel: "checkout",
  });

  assert.equal("sourceChannel" in parsed, false);
});

test("rechaza una versión distinta y una fecha sin zona", () => {
  assert.equal(PaymentConfirmedV1Schema.safeParse({
    ...validMessage,
    schemaVersion: 2,
  }).success, false);

  assert.equal(PaymentConfirmedV1Schema.safeParse({
    ...validMessage,
    occurredAt: "2026-01-15T10:30:00.000",
  }).success, false);
});

Son pruebas del esquema contra ejemplos, no una demostración de compatibilidad entre implementaciones reales.

Para verificar la integración, Payments debe producir documentos a partir de sus casos conocidos y los consumidores deben interpretarlos con sus validadores y expectativas. También hay que probar que una autorización interna no produce el mensaje y que no se filtran campos privados al serializar.

Ninguna validación estructural detectará que 7000 ahora significa ingreso neto en lugar de importe bruto. Esa diferencia requiere un acuerdo semántico y ejemplos que lo mantengan visible.

Evolucionar sin cambiar el significado a escondidas

Podemos distinguir tres clases de cambio.

Ampliación compatible para un consumidor concreto. Agregar un campo opcional como sourceChannel puede ser compatible con nuestro parser, porque ignora lo desconocido. Hay que comprobar los demás consumidores; no todos tendrán esa política.

Cambio estructural incompatible. Renombrar paymentId, exigir un campo nuevo o convertir amountMinor a cadena obliga a revisar consumidores.

Cambio semántico incompatible. Mantener todos los tipos y empezar a informar autorizaciones en lugar de capturas rompe la promesa aunque el esquema siga validando.

Añadir otra moneda tampoco es automáticamente inocuo. El consumidor mostrado acepta solo EUR, y la interpretación de unidades debe quedar acordada.

Una versión nueva no modifica retrospectivamente la anterior

Si la nueva capacidad necesita distinguir importes capturados y reembolsados, no conviertas amountMinor en saldo neto dentro de V1.

Diseña otra representación con significado explícito y decide si corresponde a otra versión o a otro tipo de hecho. Las devoluciones no son simplemente otra forma de confirmar un pago.

Un plan de transición debe identificar consumidores, ejemplos por versión, periodo de convivencia y criterio de retirada. No debe reenviar hechos antiguos como si hubieran ocurrido de nuevo solo por cambiar su formato.

La versión del esquema es distinta de la versión del paquete que contiene un validador. Actualizar una librería no cambia por sí solo el lenguaje de los documentos que circulan.

Diferencias con los conceptos anteriores

Ubiquitous Language: mantiene coherencia dentro del modelo de un contexto. Published Language expresa un acuerdo para el intercambio entre modelos. Pueden compartir términos, pero no tienen que exponer los mismos detalles.

Shared Kernel: comparte deliberadamente una parte del modelo y su evolución. Compartir una especificación de mensaje no obliga a compartir clases o comportamiento del dominio.

Open Host Service: ofrece capacidades de integración mediante un protocolo. Published Language define cómo se interpretan sus intercambios; ambos pueden complementarse.

Anti-Corruption Layer: traduce del intercambio publicado al lenguaje propio del consumidor. Un contrato bien documentado facilita esa traducción, pero no la vuelve innecesaria cuando los modelos difieren.

Una interfaz común no autoriza consultas directas a tablas de Payments ni importa sus agregados. Los límites se mantienen aunque todos comprendan el documento.

Dos ejemplos que ayudan a detectar ambigüedades

Logística: «entregado» para quién

Un transportista publica una entrega realizada. Soporte necesita saber si fue al destinatario, a un punto de recogida o a un vecino autorizado.

Un estado DELIVERED sin definición puede producir decisiones incorrectas sobre reclamaciones. Un lenguaje publicado debe precisar lo que se conoce y conservar como desconocido lo que no se registró.

No hace falta copiar el modelo completo del transportista. Hace falta una afirmación que los consumidores puedan interpretar.

Educación: una nota sin escala

Un sistema académico comunica grade: 6. Otro lo interpreta como seis sobre diez y un tercero como seis sobre siete.

El intercambio necesita escala, alcance y estado de la evaluación. Una nota provisional tampoco equivale a un resultado definitivo.

Si solo se exporta un informe para lectura humana por un único destinatario, una especificación breve puede bastar. El valor está en aclarar el significado, no en introducir herramientas pesadas sin necesidad.

Una especificación útil para la IA

Para Cursor, Claude o Codex, entrega esquema, diccionario semántico, ejemplos y reglas de compatibilidad.

Para diseñar:

Define PaymentConfirmedV1 a partir de este acuerdo: captura completa en EUR, importe bruto en céntimos, fecha UTC de registro de confirmación e identidades públicas. Separa lo afirmado de lo que no se puede deducir. No serialices el agregado ni inventes estados de pasarela.

Para generar validadores:

Implementa validación Zod 4 desde esta especificación. No conviertas cadenas numéricas. Acepta propiedades nuevas sin propagarlas al modelo local. Propón ejemplos válidos e inválidos y señala diferencias que deban comprobarse frente a JSON Schema.

Para revisar una evolución:

Compara estas dos especificaciones y los consumidores conocidos. Clasifica cambios estructurales y semánticos, identifica quién dejaría de interpretar correctamente el documento y propone una transición. No cambies la definición de V1 para hacer pasar pruebas.

Una persona debe verificar los hechos del negocio y las equivalencias entre herramientas. La generación automática puede acelerar la implementación; no decide el significado del acuerdo.

Ejercicios

1. Un nombre igual, un importe distinto

Analytics pide que amountMinor reste las comisiones, manteniendo el resto de V1. ¿Es una ampliación compatible?

2. Un campo nuevo para todos

Payments añade sourceChannel y afirma que ningún consumidor puede romperse porque el campo es opcional. ¿Qué hay que comprobar?

3. Un tipo interno conveniente

Un equipo propone publicar directamente el agregado Payment porque ya tiene todos los campos del mensaje. ¿Qué problema introduce?

Soluciones razonadas

1. No. V1 promete importe bruto capturado. Convertirlo en neto cambia su significado aunque conserve nombre y tipo. Hace falta otra representación explícita o un campo nuevo cuyo alcance esté definido, sin modificar la promesa anterior.

2. La política de deserialización y validación de cada consumidor. El parser de este artículo acepta y descarta propiedades desconocidas; uno estricto podría rechazarlas. También hay que comprobar que el cambio no modifica la interpretación de los campos existentes.

3. Acopla el intercambio al ciclo de evolución interno y puede exponer información innecesaria. Conviene construir una representación pública seleccionada y mantener su contrato independientemente del agregado.

Publicar significado, no solo estructura

Un Published Language es útil cuando varios consumidores necesitan interpretar la misma información sin reconstruir sus supuestos por separado.

Comprueba que cada campo relevante tiene definición, unidad y alcance; que los ejemplos pueden verificarse; y que hay una política para evolucionar el acuerdo.

El documento publicado permite compartir una afirmación. Cada contexto conserva su modelo y la responsabilidad sobre las decisiones que toma a partir de ella.

Cargando tus opciones de aprendizaje…

Cargando conversación…

Sigue aprendiendo