Arquitectura de Software

Domain-Driven Design: Shared Kernel, cuándo compartir el modelo de dominio entre Bounded Contexts.

Aprende a reconocer qué parte del modelo merece compartirse entre Sales y Billing. Implementa un concepto monetario pequeño en TypeScript y conoce los acuerdos, pruebas y versiones necesarios para controlar ese acoplamiento deliberado.

Cargando tus opciones de aprendizaje…

Dos equipos corrigen el mismo cálculo

Sales calcula el importe de un pedido. Billing prepara los importes de una factura. Ambos representan euros en céntimos y rechazan cantidades negativas para las operaciones que estamos estudiando.

Un equipo corrige la detección de desbordamientos; el otro mantiene una versión anterior. Después alguien propone mover Money, Customer, Order y los repositorios a un paquete llamado shared para que no vuelva a ocurrir.

La primera observación puede justificar compartir algo. La segunda propuesta necesita un límite: que dos contextos trabajen con dinero no significa que deban compartir clientes, pedidos, impuestos y persistencia.

Vamos a construir un Shared Kernel pequeño, identificar el acuerdo que lo hace posible y comprobar dónde termina. El ejemplo será una tienda hipotética con Sales y Billing; no propone modificar las reglas de dependencias de un repositorio existente.

Qué se comparte en un Shared Kernel

Un Shared Kernel, o núcleo compartido, es una parte del modelo que los equipos acuerdan compartir y mantener coordinadamente. No consiste únicamente en publicar un paquete: incluye el significado de sus conceptos, sus reglas y el compromiso de evolucionarlos juntos.

Eric Evans recomienda delimitar explícitamente ese subconjunto, mantenerlo pequeño y coordinar los cambios y su integración. Compartir introduce una interdependencia deliberada; el patrón busca administrarla, no eliminarla. DDD Reference: Shared Kernel, página 31 del documento.

Podemos representarlo así:

Sales  ------ depende de ------> commerce-money
Billing ------ depende de ----> commerce-money

commerce-money no depende de Sales ni de Billing.
Ambos equipos acuerdan su significado y evolución.

Las flechas representan dependencias de código. Sales y Billing conservan sus modelos propios; ninguno importa las entidades internas del otro.

El beneficio es mantener una definición coherente donde realmente hace falta. El coste es que una modificación del núcleo deja de ser una decisión exclusivamente local.

Primero comprueba que las reglas coinciden

Antes de crear el paquete, los dos equipos deben poder responder lo mismo ante casos concretos.

Para nuestro ejemplo acuerdan:

  • El valor representa un importe no negativo en EUR, expresado en céntimos.
  • Solo acepta enteros seguros de JavaScript.
  • Sumar importes y multiplicar por una cantidad entera no negativa debe producir otro importe válido.
  • No realiza redondeos, cambio de moneda, descuentos ni cálculo de impuestos.
  • No representa deudas, devoluciones negativas ni ajustes contables con signo.
  • No realiza peticiones ni accede a una base de datos.

Esta lista es el contrato semántico del ejemplo. No es una definición universal de dinero.

Si Billing necesita asientos negativos y Sales solo maneja importes positivos, no basta con decir «ambos usan Money». Pueden mantener un concepto positivo común para ciertas operaciones y un modelo contable separado, o decidir que compartir no compensa. Lo importante es no esconder la diferencia tras parámetros como allowNegativeWhenBilling.

Mismos campos no significa mismo concepto

Dos interfaces con amount y currency pueden representar un precio estimado, un pago recibido o un saldo pendiente. Las operaciones permitidas y su significado pueden diferir.

La pregunta útil es: si una regla cambia, ¿los dos equipos deberían querer el mismo cambio por la misma razón?

Una respuesta afirmativa no garantiza que compartir sea conveniente. Todavía faltan estabilidad, tamaño y capacidad de coordinación. Una respuesta negativa es una señal para conservar modelos separados.

Un límite pequeño para Sales y Billing

El paquete se llamará @commerce/money-kernel. El nombre es ilustrativo y no corresponde a una dependencia que debas instalar.

La estructura mínima propuesta es:

packages/
  money-kernel/
    src/
      index.ts
    test/
      money.test.ts
    package.json
    README.md

contexts/
  sales/
    application/
      order-total.ts
    test/
      order-total.test.ts
  billing/
    application/
      invoice-subtotal.ts
    test/
      invoice-subtotal.test.ts

El README debe registrar el acuerdo anterior, los equipos responsables y qué queda fuera. El paquete no contiene Customer, Order, entidades ORM ni repositorios.

No mostramos la configuración de compilación o del workspace: los imports por nombre de paquete de las próximas secciones presuponen que cada paquete está compilado y resuelto por el proyecto. Son fragmentos de diseño, no un monorepo listo para copiar y ejecutar.

En un proyecto que prohíbe compartir código de dominio entre contextos, introducir este patrón exige revisar esa decisión. Un ejemplo educativo no autoriza a saltarse sus límites actuales.

Implementar el concepto monetario acordado

Este es el contenido propuesto de money-kernel/src/index.ts. La clase queda autocontenida en el archivo y no necesita librerías externas.

export class NonNegativeEurAmount {
  readonly #cents: number;

  private constructor(cents: number) {
    this.#cents = cents;
    Object.freeze(this);
  }

  static fromCents(cents: number): NonNegativeEurAmount {
    if (!Number.isSafeInteger(cents) || cents < 0) {
      throw new Error("El importe debe ser un entero seguro no negativo");
    }

    return new NonNegativeEurAmount(cents);
  }

  get cents(): number {
    return this.#cents;
  }

  get currency(): "EUR" {
    return "EUR";
  }

  add(other: NonNegativeEurAmount): NonNegativeEurAmount {
    return NonNegativeEurAmount.fromCents(this.#cents + other.#cents);
  }

  multiply(quantity: number): NonNegativeEurAmount {
    if (!Number.isSafeInteger(quantity) || quantity < 0) {
      throw new Error("La cantidad debe ser un entero seguro no negativo");
    }

    return NonNegativeEurAmount.fromCents(this.#cents * quantity);
  }

  equals(other: NonNegativeEurAmount): boolean {
    return this.#cents === other.#cents;
  }
}

El nombre hace visible la restricción. No promete soportar cualquier moneda o cálculo financiero.

fromCents(3500) representa 35 euros. La fábrica vuelve a validar los resultados de suma y multiplicación; si salen del rango entero seguro, la operación falla. No usamos aritmética decimal para calcular impuestos o tasas porque esa responsabilidad está fuera del acuerdo.

La clase solo expone su importe y operaciones acotadas. El campo privado y la ausencia de mutadores evitan que una operación cambie el objeto anterior. Object.freeze expresa además la intención de no añadir propiedades a la instancia.

Los constructores privados de TypeScript son restricciones de compilación, no una frontera de seguridad frente a código hostil. Y el getter cents no convierte cualquier JSON externo en un importe válido: cada contexto debe validar sus entradas y reconstruir el valor mediante fromCents.

Dos consumidores con decisiones propias

Sales calcula las líneas de un pedido

El paquete compartido no decide qué productos pueden comprarse ni qué promociones existen. Sales valida la cantidad comercial y utiliza la operación monetaria acordada.

import { NonNegativeEurAmount } from "@commerce/money-kernel";

export interface SalesLine {
  readonly unitPrice: NonNegativeEurAmount;
  readonly quantity: number;
}

export function calculateOrderTotal(
  lines: readonly SalesLine[],
): NonNegativeEurAmount {
  if (lines.length === 0) {
    throw new Error("El pedido necesita al menos una línea");
  }

  return lines.reduce((total, line) => {
    if (!Number.isSafeInteger(line.quantity) || line.quantity <= 0) {
      throw new Error("La cantidad de compra debe ser positiva");
    }

    return total.add(line.unitPrice.multiply(line.quantity));
  }, NonNegativeEurAmount.fromCents(0));
}

El núcleo permite multiplicar por cero porque es una operación monetaria válida dentro del acuerdo. Sales prohíbe comprar cero unidades porque es una regla de esa operación comercial.

Así evitamos que la clase compartida conozca conceptos como carrito, reserva o pedido mínimo.

Billing calcula un subtotal, no toda la factura

Billing puede trabajar con líneas que ya tienen su importe calculado. Su función local decide qué colección necesita para preparar el subtotal.

import { NonNegativeEurAmount } from "@commerce/money-kernel";

export function calculateInvoiceSubtotal(
  lineAmounts: readonly NonNegativeEurAmount[],
): NonNegativeEurAmount {
  if (lineAmounts.length === 0) {
    throw new Error("La factura necesita al menos una línea");
  }

  return lineAmounts.reduce(
    (subtotal, amount) => subtotal.add(amount),
    NonNegativeEurAmount.fromCents(0),
  );
}

Sales y Billing comparten aritmética y representación, pero no una entidad Document genérica con condiciones para cada contexto.

El resultado del ejemplo es limitado: un subtotal. No hemos calculado impuestos, emitido documentos fiscales ni implementado devoluciones. Si Billing requiere esas capacidades, debe modelarlas localmente o revisar un acuerdo nuevo y explícito.

Probar el acuerdo y probar los usos

Una suite compartida comprueba las reglas del núcleo. Las suites de los consumidores comprueban que cada contexto lo utiliza correctamente. La primera no sustituye a las segundas.

Estos son ejemplos de pruebas unitarias con las APIs integradas de Node.js. Los archivos presuponen compilación de TypeScript a JavaScript ESM y resolución del paquete; no se han ejecutado como parte de este artículo.

Reglas del núcleo

import test from "node:test";
import assert from "node:assert/strict";
import { NonNegativeEurAmount } from "../src/index.js";

test("suma sin cambiar los importes originales", () => {
  const first = NonNegativeEurAmount.fromCents(3500);
  const second = NonNegativeEurAmount.fromCents(500);

  assert.equal(first.add(second).cents, 4000);
  assert.equal(first.cents, 3500);
  assert.equal(second.cents, 500);
});

test("rechaza importes negativos y fracciones de céntimo", () => {
  assert.throws(() => NonNegativeEurAmount.fromCents(-1));
  assert.throws(() => NonNegativeEurAmount.fromCents(1.5));
});

test("rechaza cantidades inválidas y resultados no seguros", () => {
  const amount = NonNegativeEurAmount.fromCents(100);

  assert.throws(() => amount.multiply(-1));
  assert.throws(() => amount.multiply(0.5));

  const maximum = NonNegativeEurAmount.fromCents(Number.MAX_SAFE_INTEGER);
  assert.throws(() => maximum.add(NonNegativeEurAmount.fromCents(1)));
  assert.throws(() => maximum.multiply(2));
});

test("representa cero y compara por el importe acordado", () => {
  const zero = NonNegativeEurAmount.fromCents(0);

  assert.equal(zero.currency, "EUR");
  assert.equal(zero.multiply(4).cents, 0);
  assert.equal(zero.equals(NonNegativeEurAmount.fromCents(0)), true);
});

Expectativas de los consumidores

En Sales:

import test from "node:test";
import assert from "node:assert/strict";
import { NonNegativeEurAmount } from "@commerce/money-kernel";
import { calculateOrderTotal } from "../application/order-total.js";

test("Sales calcula dos unidades al precio aceptado", () => {
  const total = calculateOrderTotal([{
    unitPrice: NonNegativeEurAmount.fromCents(3500),
    quantity: 2,
  }]);

  assert.equal(total.cents, 7000);
});

test("Sales no acepta una línea con cero unidades", () => {
  assert.throws(() => calculateOrderTotal([{
    unitPrice: NonNegativeEurAmount.fromCents(3500),
    quantity: 0,
  }]));
});

En Billing:

import test from "node:test";
import assert from "node:assert/strict";
import { NonNegativeEurAmount } from "@commerce/money-kernel";
import { calculateInvoiceSubtotal } from "../application/invoice-subtotal.js";

test("Billing suma importes de líneas ya calculadas", () => {
  const subtotal = calculateInvoiceSubtotal([
    NonNegativeEurAmount.fromCents(7000),
    NonNegativeEurAmount.fromCents(500),
  ]);

  assert.equal(subtotal.cents, 7500);
});

test("Billing exige líneas para preparar el subtotal", () => {
  assert.throws(() => calculateInvoiceSubtotal([]));
});

Estas pruebas dejan una distinción importante: el valor cero pertenece al modelo compartido, mientras las condiciones para aceptar una línea o un documento siguen en cada contexto.

El gobierno del paquete también es parte del patrón

Sin un acuerdo de cambio, money-kernel puede convertirse en una biblioteca que un equipo modifica y el otro sufre.

Una política sencilla para este ejemplo sería:

  1. Registrar qué necesidad motiva el cambio y si afecta al significado existente.
  2. Pedir revisión a responsables de Sales y Billing antes de modificar el núcleo.
  3. Actualizar pruebas compartidas y casos de uso afectados en ambos consumidores.
  4. Publicar una versión identificable con notas de comportamiento.
  5. Acordar cómo y cuándo cada consumidor la incorpora.

No todos los equipos necesitan el mismo proceso. Lo esencial es que nadie cambie unilateralmente una regla cuyo significado ambos prometen compartir.

Tampoco hace falta pedir autorización al otro equipo para cada cambio interno de Sales. La coordinación pertenece al núcleo y a los acuerdos que lo rodean, no a todo el desarrollo.

Versionar sin esconder un cambio de significado

Un paquete podría declarar estas propiedades de forma ilustrativa:

{
  "name": "@commerce/money-kernel",
  "version": "1.0.0",
  "type": "module",
  "exports": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

Este fragmento supone que la compilación genera los archivos indicados. No publica el paquete ni configura un registro.

SemVer distingue cambios incompatibles de API, funcionalidad compatible y correcciones compatibles mediante versiones mayor, menor y parche. Para aplicarlo, primero debes definir la API pública; en este caso también documentamos las reglas que los consumidores esperan. Especificación de Versionado Semántico.

Tres decisiones posibles:

  • 1.0.0 → 1.0.1: corregir documentación o un defecto que restablece el comportamiento documentado, revisando el impacto real.
  • 1.0.0 → 1.1.0: agregar una operación compatible, como una comparación que conserva todas las reglas existentes.
  • 1.0.0 → 2.0.0: cambiar la unidad de entrada o eliminar una operación pública que usan los consumidores.

Aceptar negativos no es una mejora inocente para una clase llamada NonNegativeEurAmount. Antes de elegir un número de versión, revisa si la propuesta rompe el concepto o debería crear otro tipo fuera del núcleo.

Un cambio puede compilar y seguir siendo incompatible semánticamente. Pasar de céntimos a euros sin cambiar el tipo number es un ejemplo: 3500 deja de significar lo mismo.

Adoptar una versión requiere comprobarla

En este ejemplo, una dependencia exacta como "@commerce/money-kernel": "1.0.0", junto con el lockfile del proyecto, hace explícita la versión seleccionada. No convierte el acuerdo en permanente ni evita vulnerabilidades o errores por sí sola.

Antes de actualizar, ejecuta la suite del núcleo y las de ambos consumidores contra la versión candidata. En un monolito pueden integrarse juntos. En aplicaciones con entregas separadas puede ser necesario un periodo de compatibilidad y una fecha de adopción acordada.

Versionar no consiste en permitir que cada equipo se aleje indefinidamente con reglas diferentes. Si eso ocurre, revisa si sigue existiendo un modelo realmente compartido.

Por qué Customer no debería entrar por comodidad

Considera estos fragmentos de modelos locales:

interface SalesCustomer {
  readonly customerId: string;
  readonly purchasingEnabled: boolean;
}

interface BillingRecipient {
  readonly recipientId: string;
  readonly legalName: string;
  readonly taxIdentifier: string;
}

El primero permite decidir sobre una relación comercial. El segundo representa datos necesarios para un documento de facturación.

Crear un SharedCustomer con todos los campos introduce preguntas ajenas en cada contexto: ¿puede existir un comprador sin identificación fiscal?, ¿debe bloquearse una compra por una regla de emisión?, ¿una corrección del nombre modifica documentos anteriores?

Compartir entidades suele ampliar el área de coordinación porque transporta identidad, comportamiento y ciclo de vida. No es imposible acordar una parte compartida, pero exige mucha más evidencia que encontrar dos propiedades llamadas id y name.

Para esta tienda conservamos modelos separados y acordamos qué información cruza el límite. La traducción del consumidor no tiene que importar la entidad interna del otro contexto.

Qué es y qué no es el núcleo compartido

Biblioteca de utilidades. Formatear logs o medir tiempos reutiliza código técnico. Puede tener varios consumidores, pero no comparte necesariamente un modelo del negocio.

Módulo compartido. Describe una forma de organizar o distribuir código. Se convierte en Shared Kernel cuando existe un subconjunto de dominio compartido con significado y gobierno acordados.

Contrato de integración. Describe lo intercambiado. Dos contextos pueden entender el mismo JSON y conservar implementaciones y modelos diferentes.

Published Language. Ofrece un lenguaje de intercambio documentado. No obliga a adoptar una clase común para todos los participantes.

Anti-Corruption Layer. Traduce al modelo consumidor. Se utiliza precisamente cuando conviene conservar interpretaciones distintas, aunque pueda coexistir con un núcleo compartido para otros conceptos.

Compartir NonNegativeEurAmount tampoco significa enviar instancias de clase por la red. Un contrato podría transportar moneda y céntimos; el receptor valida esos datos y reconstruye su valor. El formato y su evolución merecen su propio acuerdo.

Dos decisiones diferentes fuera de la tienda

Reservas: compartir un intervalo cuando la interpretación coincide

Planificación y reservas usan franjas con inicio incluido y fin excluido, expresadas como instantes UTC. Si comparten esas reglas y pueden coordinar cambios, un pequeño concepto de intervalo podría ser candidato.

Pero si planificación habla de días del calendario local y reservas de instantes exactos, una clase idéntica ocultaría una diferencia. El aprendizaje es comprobar ejemplos concretos de borde antes de extraer el tipo.

Educación: conservar dos notas distintas

Un módulo académico maneja notas con decimales y reglas institucionales. Una actividad lúdica muestra estrellas de una a cinco. Ambos usan un número y una etiqueta, pero no comparten escala, reglas ni propósito.

Mantener dos modelos pequeños es más claro que crear Score con opciones para cada caso. La duplicación estructural puede ser un coste menor que coordinar una abstracción falsa.

Señales de que el núcleo está creciendo demasiado

Detente si aparecen métodos como calculateSalesDiscount, dependencias de repositorios o condiciones por nombre de contexto.

Otras señales son que cualquier funcionalidad requiere publicar el paquete, que un equipo acumula excepciones para no romper al otro o que las revisiones se aprueban sin entender las reglas compartidas.

No resuelvas todo agregando interfaces genéricas. Revisa si la responsabilidad sigue siendo común. Sacar una regla del núcleo puede ser una mejora aunque aumente algunas líneas de código.

Si ya no pueden coordinar cambios o sus necesidades divergen, prepara una separación: introduce tipos locales, traduce en los bordes y retira gradualmente las dependencias. No cambies de golpe las unidades o el significado de datos persistidos.

Orientar a una IA sin generar un paquete global

Estos prompts sirven para Cursor, Claude o Codex, acompañados de ejemplos del negocio:

Para evaluar candidatos:

Compara los modelos de Sales y Billing. Identifica solo reglas con el mismo significado, entradas válidas y operaciones en ambos contextos. Distingue evidencia de suposiciones. Propón un núcleo mínimo o justifica mantenerlos separados. No extraigas entidades por coincidencia de nombres.

Para implementar el acuerdo:

Implementa un paquete independiente para importes no negativos en EUR expresados en céntimos enteros seguros. Incluye suma, multiplicación por entero no negativo y pruebas de desbordamiento. Excluye impuestos, descuentos, conversión, repositorios y dependencias de los contextos. Separa reglas locales de Sales y Billing.

Para revisar una actualización:

Revisa este cambio del núcleo contra su contrato público y los casos de ambos consumidores. Busca cambios de unidades, nuevos valores aceptados y responsabilidades particulares introducidas en el paquete. Propón la versión y los pasos de adopción con una justificación. Señala lo que debe validar el negocio.

Una persona debe confirmar el significado, la compatibilidad y el compromiso de los equipos. La IA puede detectar duplicación; no puede deducir de ella que exista un acuerdo de dominio.

Ejercicios

1. La devolución negativa

Billing necesita registrar una devolución de −3500 céntimos y propone eliminar la validación de negativos. Sales utiliza el tipo para precios de compra. ¿Qué decisión tomarías antes de modificarlo?

2. El descuento compartido

Sales quiere introducir applyCampaignDiscount en el núcleo porque opera sobre dinero. Billing no decide campañas. ¿Pertenece esa función al paquete?

3. La actualización compatible que cambia importes

Una versión conserva las mismas firmas, pero empieza a interpretar fromCents(100) como 100 euros. Sus autores la publican como parche. ¿Qué falló?

Soluciones razonadas

1. Identificaría si una devolución representa otro concepto o si ambos quieren un importe con signo. No debilitaría un tipo no negativo para satisfacer una necesidad local. Billing puede modelar la devolución como dirección y magnitud positiva, o usar un tipo con signo propio, según sus reglas. Cualquier cambio del acuerdo requiere revisión conjunta.

2. Operar sobre dinero no convierte una regla en compartida. El descuento de campaña pertenece a Sales mientras Billing no tenga exactamente esa responsabilidad. Sales puede calcular el resultado y construir un importe válido utilizando las operaciones acordadas.

3. Se ignoró la semántica de la API pública. El código consumidor puede compilar y producir importes cien veces mayores. Hacían falta pruebas con valores conocidos, revisión conjunta y un plan explícito; el cambio de unidad es incompatible aunque la firma siga usando number.

Cuándo compartir merece el compromiso

Un Shared Kernel tiene sentido cuando puedes delimitar una parte pequeña del modelo, demostrar que sus reglas coinciden y sostener su evolución entre todos los responsables.

Antes de extraer una clase, escribe tres cosas: qué significa, qué no hará y cómo se aprobará un cambio. Si no pueden acordarlas, conservar modelos independientes puede ser la opción más clara.

El paquete es la consecuencia del acuerdo. Mantener ese acuerdo pequeño y verificable es lo que evita convertirlo en un modelo global del negocio.

Cargando tus opciones de aprendizaje…

Cargando conversación…

Sigue aprendiendo