Arquitectura de Software

Domain-Driven Design: Domain Services, lógica de negocio que no pertenece a una Entity

Aprende a reconocer una operación que necesita un Domain Service y a distinguirla de la coordinación y las integraciones técnicas. Diseña una selección de transporte en TypeScript, conserva las reglas propias de las Entities y verifica tus decisiones con pruebas y criterios para revisar código generado por IA.

Cargando tus opciones de aprendizaje…

Una tienda necesita elegir un transportista. La opción más barata puede no cubrir el destino, admitir el peso del paquete o cumplir el plazo prometido. La decisión depende del envío y de varias ofertas; ninguna oferta aislada puede saber si es la mejor alternativa.

La primera implementación termina en ShippingService: consulta proveedores, filtra opciones, asigna un transportista, guarda el envío y responde HTTP. Cuando el equipo quiere probar la regla de selección, descubre que necesita simular toda la operación.

Aquí hay una decisión del negocio que merece un nombre propio. También hay tareas de coordinación y comunicación que deben distinguirse de ella. Un Domain Service permite expresar la primera sin convertir toda la operación en una clase que hace de todo.

Qué hace que un servicio sea un Domain Service

Un Domain Service representa una operación significativa del negocio que no encaja naturalmente como responsabilidad de una Entity o un Value Object. Su interfaz utiliza conceptos del modelo y el servicio no conserva estado propio de una operación entre llamadas. Estos criterios proceden de la sección Services de la referencia de DDD de Eric Evans.

No significa «cualquier clase terminada en Service». Tampoco basta con que una función reciba dos objetos: sumar dos cantidades monetarias puede seguir siendo comportamiento de Money.

La pregunta útil es: ¿quién puede asumir esta decisión sin deformar su responsabilidad?

En la selección de transporte, Shipment conoce sus características y controla si puede recibir una asignación. Cada oferta describe una posibilidad. La comparación entre alternativas, con prioridades comerciales, puede expresarse como ShippingOptionSelector.

Ese nombre señala una decisión concreta. ShippingManager, en cambio, no permite saber si selecciona opciones, imprime etiquetas o registra incidencias.

Domain, Application e Infrastructure: tres responsabilidades distintas

Un Application Service implementa o coordina un Use Case, una operación que ofrece la aplicación: obtiene la información necesaria, invoca el modelo y organiza el guardado o los efectos posteriores. No debería duplicar las reglas que pertenecen al Domain.

Un Infrastructure Service resuelve un mecanismo técnico: consultar una API de transporte, enviar un correo o acceder a almacenamiento. Puede interpretar protocolos y traducir formatos, pero eso no convierte automáticamente sus decisiones en reglas del negocio.

Un Domain Service responde una pregunta del modelo: dadas estas características y estas alternativas válidas, ¿cuál debemos elegir?

La distinción se puede leer así:

Application: obtener ofertas, pedir una selección y coordinar la asignación.
Domain Service: elegir según cobertura, peso, plazo y prioridad comercial.
Entity: aceptar o rechazar la asignación según su estado.
Infrastructure: consultar al proveedor y validar su respuesta técnica.

Fowler describe la coordinación de operaciones de aplicación en su patrón Service Layer. Esa separación ayuda a distinguir responsabilidades, aunque el término «service» se utilice con significados diferentes en cada arquitectura.

Antes: una sola función mezcla mecanismos y decisiones

Este fragmento parcial utiliza colaboradores ficticios: carrierApi consulta ofertas y shipmentStore guarda la asignación. No es un endpoint ejecutable:

async function processShipment(req, res) {
  const offers = await carrierApi.getOffers(req.body.shipmentId);
  const selected = offers
    .filter(offer => offer.priceMinorUnits >= 0)
    .sort((a, b) => a.priceMinorUnits - b.priceMinorUnits)[0];
  await shipmentStore.assignCarrier(req.body.shipmentId, selected.carrierId);
  res.json({ carrierId: selected.carrierId });
}

Además de mezclar responsabilidades, el criterio es insuficiente: la oferta más barata podría incumplir peso, zona o plazo, y puede no existir ninguna oferta. Mover el bloque completo a domain/shipping.service.ts conservaría ambos problemas.

La mejora exige dos pasos diferentes: acordar la regla correcta y ubicar cada responsabilidad. Una estructura de carpetas no decide cuál de las ofertas cumple una promesa comercial.

Ejemplo completo: elegir una oferta de transporte

Reglas y límites del caso

Para esta tienda elegiremos la oferta de menor precio entre las que:

  • Cubren exactamente la zona del envío.
  • Admiten su peso, incluido el límite máximo anunciado.
  • Tienen un plazo de tránsito menor o igual al máximo solicitado.

Si dos ofertas cuestan lo mismo, gana el identificador de transportista que aparece primero según la comparación de cadenas de JavaScript. Es una regla de desempate didáctica y determinista. Si no hay opciones compatibles, el resultado expresa que no existe una selección.

Todos los importes están en céntimos de EUR. Los plazos son días enteros de tránsito, sin calendarios, festivos ni horarios de recogida. No evaluamos capacidad instantánea ni garantizamos que una oferta siga vigente después de seleccionarla.

Usaremos un Value Object mínimo, Weight, y una Entity, Shipment. Solo implementan lo necesario para colaborar con el servicio. Las ofertas son estructuras de entrada conocidas y validadas por quien las obtiene; el ejemplo no es un parser de respuestas externas.

Programa autocontenido

Guarda este bloque como domain-service.ts y ejecútalo con node domain-service.ts usando Node.js 24.21.0. No requiere paquetes externos. La ejecución directa elimina anotaciones de TypeScript mediante type stripping, pero no realiza comprobación estática de tipos.

import assert from 'node:assert/strict';

type Zone = 'local' | 'regional';

class Weight {
  readonly grams: number;

  constructor(grams: number) {
    if (!Number.isSafeInteger(grams) || grams <= 0) {
      throw new Error('El peso debe ser un entero positivo de gramos');
    }
    this.grams = grams;
    Object.freeze(this);
  }
}

class Shipment {
  #id: string;
  #weight: Weight;
  #zone: Zone;
  #carrierId: string | null = null;

  constructor(id: string, weight: Weight, zone: Zone) {
    if (!id.trim()) throw new Error('El envío necesita un identificador');
    this.#id = id;
    this.#weight = weight;
    this.#zone = zone;
  }

  get id(): string { return this.#id; }
  get weight(): Weight { return this.#weight; }
  get zone(): Zone { return this.#zone; }
  get carrierId(): string | null { return this.#carrierId; }

  assignCarrier(carrierId: string): void {
    if (this.#carrierId !== null) throw new Error('El envío ya tiene transportista');
    if (!carrierId.trim()) throw new Error('El transportista necesita identidad');
    this.#carrierId = carrierId;
  }
}

type ShippingOffer = Readonly<{
  carrierId: string;
  zone: Zone;
  maxWeightGrams: number;
  transitDays: number;
  priceMinorUnits: number;
}>;

type Selection =
  | { kind: 'unavailable' }
  | { kind: 'selected'; carrierId: string; priceMinorUnits: number };

class ShippingOptionSelector {
  select(
    shipment: Shipment,
    offers: readonly ShippingOffer[],
    maxTransitDays: number,
  ): Selection {
    if (!Number.isSafeInteger(maxTransitDays) || maxTransitDays <= 0) {
      throw new Error('El plazo máximo debe ser un entero positivo');
    }

    const candidates = offers.filter(offer =>
      offer.zone === shipment.zone &&
      offer.maxWeightGrams >= shipment.weight.grams &&
      offer.transitDays <= maxTransitDays);

    candidates.sort((a, b) => {
      if (a.priceMinorUnits !== b.priceMinorUnits) {
        return a.priceMinorUnits < b.priceMinorUnits ? -1 : 1;
      }
      if (a.carrierId === b.carrierId) return 0;
      return a.carrierId < b.carrierId ? -1 : 1;
    });

    const best = candidates[0];
    return best
      ? { kind: 'selected', carrierId: best.carrierId, priceMinorUnits: best.priceMinorUnits }
      : { kind: 'unavailable' };
  }
}

const offers: ShippingOffer[] = [
  { carrierId: 'cheap-slow', zone: 'local', maxWeightGrams: 5000,
    transitDays: 5, priceMinorUnits: 200 },
  { carrierId: 'small-only', zone: 'local', maxWeightGrams: 1000,
    transitDays: 1, priceMinorUnits: 300 },
  { carrierId: 'regional', zone: 'regional', maxWeightGrams: 5000,
    transitDays: 1, priceMinorUnits: 350 },
  { carrierId: 'carrier-b', zone: 'local', maxWeightGrams: 2000,
    transitDays: 2, priceMinorUnits: 500 },
  { carrierId: 'carrier-a', zone: 'local', maxWeightGrams: 2000,
    transitDays: 2, priceMinorUnits: 500 },
];

const shipment = new Shipment('shipment-1', new Weight(2000), 'local');
const selector = new ShippingOptionSelector();
const beforeSelection = JSON.stringify(offers);
const result = selector.select(shipment, offers, 2);

assert.deepEqual(result, {
  kind: 'selected', carrierId: 'carrier-a', priceMinorUnits: 500,
});
assert.equal(shipment.carrierId, null);
assert.equal(JSON.stringify(offers), beforeSelection);
assert.deepEqual(selector.select(shipment, offers, 1), { kind: 'unavailable' });
assert.deepEqual(selector.select(shipment, [], 2), { kind: 'unavailable' });
assert.throws(() => selector.select(shipment, offers, 0), /entero positivo/);

if (result.kind === 'selected') shipment.assignCarrier(result.carrierId);
assert.equal(shipment.carrierId, 'carrier-a');
assert.throws(() => shipment.assignCarrier('carrier-b'), /ya tiene/);
console.log('Selección, límites y responsabilidad de la Entity verificados');

Qué decidió cada colaborador

La entrada es un envío local de 2.000 gramos con un máximo de dos días. La oferta de 200 céntimos no cumple el plazo; la de 300 no admite el peso; la regional no cubre la zona. Quedan dos opciones de 500 céntimos y el desempate selecciona carrier-a.

El Domain Service no modifica el envío ni el arreglo original. filter() produce otro arreglo y la ordenación se aplica a ese resultado. La selección devuelve una decisión explícita, no inicia llamadas externas.

Después, Shipment.assignCarrier() controla su propia regla: no admitir una segunda asignación. Esa Invariant, una condición que debe mantenerse tras operaciones válidas, sigue siendo responsabilidad de la Entity. El servicio no escribe un campo público para saltársela.

Las aserciones comprueban cobertura, peso en el límite, plazo en el límite, desempate y ausencia de alternativas. También muestran que seleccionar y asignar son operaciones diferentes. El programa se ejecutó con Node.js 24.21.0 y completó las aserciones incluidas. No se realizó comprobación estática con TypeScript ni se probó disponibilidad real de transportistas o persistencia.

Las ofertas válidas deben contener identificadores no vacíos, precios enteros no negativos, pesos positivos y plazos admitidos. En una integración real, ese contrato necesita validación durante la ejecución: Readonly y las anotaciones de TypeScript no validan JSON. Si esa estructura empieza a necesitar más comportamiento propio, puedes introducir otro concepto del modelo; aquí no es necesario desarrollarlo.

Cómo lo utilizaría un Application Service

El siguiente fragmento se puede añadir al programa anterior. Ilustra coordinación en memoria y depende de sus tipos; no es otro proyecto autocontenido ni implementa persistencia:

interface ShippingOfferReader {
  findFor(shipment: Shipment): Promise<readonly ShippingOffer[]>;
}

class AssignShippingOption {
  #reader: ShippingOfferReader;
  #selector: ShippingOptionSelector;

  constructor(reader: ShippingOfferReader, selector: ShippingOptionSelector) {
    this.#reader = reader;
    this.#selector = selector;
  }

  async execute(shipment: Shipment, maxTransitDays: number): Promise<Selection> {
    const offers = await this.#reader.findFor(shipment);
    const result = this.#selector.select(shipment, offers, maxTransitDays);
    if (result.kind === 'selected') shipment.assignCarrier(result.carrierId);
    return result;
  }
}

Las dependencias son explícitas en el constructor. Una implementación de ShippingOfferReader puede consultar una API HTTP, validar la respuesta y traducirla. El Use Case decide cuándo obtenerla; el selector decide qué alternativa cumple la política.

El ejemplo ya recibe la Entity. Una operación real también tendría que recuperarla y guardar el cambio, además de comprobar permisos. Si la consulta falla, no debería traducirse automáticamente a una lista vacía: «no hay ofertas compatibles» no equivale a «no pude consultar».

Tampoco puedes garantizar la asignación solo porque el selector haya elegido bien. La oferta podría cambiar o dos operaciones podrían modificar el mismo envío. Necesitas acuerdos de vigencia y mecanismos de concurrencia al integrar y guardar; un cálculo correcto no sustituye esas garantías.

Sin estado no significa sin dependencias

El selector no recuerda el último envío, la lista de candidatos ni el último resultado. Cada llamada recibe los datos necesarios. Eso lo hace stateless, es decir, sin estado de una operación que afecte accidentalmente a la siguiente.

Puede tener dependencias estables, como una política de prioridad o una configuración inmutable. Eso no equivale a guardar datos de la solicitud anterior en propiedades de la instancia.

También conviene distinguir stateless de función pura. Un servicio sin estado puede consultar un colaborador y depender de datos que cambian. Para pruebas predecibles necesitas hacer explícita esa colaboración. Cuando sea razonable, pasar hechos ya obtenidos, como hicimos con las ofertas, permite mantener la decisión separada de la consulta.

Un Domain Service no está obligado a ser una clase. Si una función con nombre de negocio expresa la operación y sus dependencias con claridad, puede ser suficiente. La ausencia de una clase terminada en Service no elimina la responsabilidad del Domain.

Segundo escenario: una cancelación que debe quedarse en la Entity

Un equipo propone OrderService.cancel(order) para comprobar que el pedido no fue despachado y después modificar su estado mediante setters. Toda la información y la transición pertenecen al propio pedido.

En ese caso, Order.cancel() es un lugar más natural. La Entity controla si su estado admite cancelar y evita que otras entradas apliquen una transición distinta. El Use Case carga el pedido, pide cancelar y guarda el resultado.

Extraer la regla solo para crear un servicio vacía de comportamiento a la Entity. Ese desplazamiento sistemático es una señal del Anemic Domain Model descrito por Fowler.

Ahora añade una política comercial que compara varios pedidos de una cuenta para decidir qué alternativas de compensación se ofrecen. Esa decisión podría justificar un Domain Service, pero no le concede permiso para modificar cualquier pedido sin pasar por sus métodos.

Este escenario enseña a separar una transición propia de una Entity de una decisión que relaciona varios conceptos. Contar objetos o líneas de código no resuelve esa elección.

Tercer escenario: educación y una política que consulta historial

Una academia necesita decidir si una persona puede solicitar un examen de certificación. La decisión combina requisitos de la certificación, cursos aprobados y una restricción sobre intentos recientes. Ninguna inscripción individual contiene por sí sola la política completa.

Un CertificationEligibilityPolicy puede evaluar esa evidencia y devolver un resultado con motivos de rechazo. El Application Service obtiene el historial y coordina la solicitud. La infraestructura sabe consultarlo; no debería inventar si un curso externo equivale al requisito exigido.

Si la evidencia pertenece a otro Bounded Context, el límite donde otro modelo tiene un significado consistente, evita importar sus implementaciones. Una Anticorruption Layer, o ACL, puede consultar por HTTP y traducir la información a un contrato del consumidor.

La política debe distinguir «requisito no cumplido» de «evidencia no disponible». Además, una comprobación previa no reserva automáticamente un intento de examen: si existen límites concurrentes, hay que protegerlos donde se registra la solicitud. La consulta HTTP y el guardado local no forman una transacción compartida.

Aquí aprendes a separar la obtención de conocimiento de su evaluación y a reconocer los límites de una decisión tomada con una lectura concreta.

Cómo evitar un God Service

Un God Service concentra demasiadas responsabilidades y acaba convirtiéndose en el lugar donde termina cualquier cambio. En transporte, una misma clase podría seleccionar opciones, facturar, imprimir etiquetas y enviar correos porque todo «tiene que ver con envíos».

La relación temática no basta para justificar esa agrupación. Revisa qué reglas cambian juntas y qué preguntas responde cada operación. Si modificar el proveedor de correo obliga a revisar la selección de transporte, probablemente los límites no estén ayudando.

Tampoco lo soluciones creando una clase por cada línea. Empieza por una decisión reconocible: seleccionar transporte, evaluar elegibilidad o calcular una compensación. Mantén dentro de las Entities y los Value Objects el comportamiento que les corresponde.

Un helper que formatea una fecha no se convierte en Domain Service por estar en la carpeta domain. Debe expresar una operación del modelo, no solo una utilidad técnica reutilizable.

Cuándo una solución más simple es suficiente

Si una operación solo necesita comprobar un dato local y cambiar el estado de una Entity, utiliza su método. Si debes presentar un listado sin reglas relevantes, una consulta simple puede resolverlo. No introduzcas un Domain Service para que todas las peticiones pasen por una clase adicional.

Incluso una selección puede empezar como una función pequeña. Extraer un servicio merece la pena cuando da nombre a una decisión del negocio, concentra una política que de otro modo se duplicaría o hace explícitas colaboraciones relevantes. La repetición de código es una señal posible, no un requisito para que exista esa responsabilidad.

Prompts para dirigir y revisar código generado por IA

Decidir dónde pertenece una regla

Analiza estas operaciones de transporte y pedidos. Clasifica cada decisión como comportamiento de Entity o Value Object, Domain Service, coordinación de Application o integración técnica. Justifica con la información necesaria y quién posee la transición. No edites archivos ni crees servicios por defecto. Devuelve dudas y contraejemplos. Una persona validará las políticas comerciales y los límites del modelo antes de aceptar la clasificación.

Implementar una política acotada

Implementa ShippingOptionSelector en TypeScript sin dependencias. Recibe un envío y ofertas ya validadas en céntimos EUR. Filtra zona, peso máximo inclusivo y plazo máximo inclusivo; elige menor precio y desempata por identificador. Devuelve unavailable si no hay candidato. No consultes HTTP, guardes datos ni modifiques la Entity. Entrega código y aserciones de límites, desempate y no mutación. Una persona ejecutará las pruebas y comprobará el significado de plazo y vigencia.

Revisar sin vaciar el modelo

Revisa el diff contra las reglas adjuntas. Busca reglas propias de Entities trasladadas a servicios, propiedades con datos de solicitudes anteriores, dependencias técnicas ocultas y consultas fallidas tratadas como rechazos del negocio. No cambies archivos. Devuelve evidencia y un escenario observable por hallazgo; marca las dudas. Una persona revisará concurrencia y efectos externos, que las pruebas del selector no garantizan.

Que la IA genere una clase pequeña y pruebas que pasen no demuestra que la responsabilidad esté bien ubicada. Contrasta si la operación representa una decisión real y si la Entity sigue protegiendo sus cambios.

Tres ejercicios con solución razonada

1. La oferta más barata tarda demasiado

La IA ordena por precio y rechaza la primera oferta si incumple el plazo, sin revisar las siguientes. ¿Qué resultado incorrecto podría producir?

Solución razonada: devolver que no hay opciones aunque exista otra más cara que sí cumple. Primero debe construir el conjunto elegible y después elegir dentro de él. Una prueba con una oferta barata y lenta y otra compatible protege la secuencia de decisiones.

2. Un servicio recuerda el último envío

Para evitar pasar parámetros, el selector guarda currentShipment y lastOffers en propiedades. La misma instancia atiende varias operaciones. ¿Qué cambiarías?

Solución razonada: recibir esos datos en cada llamada y mantener candidatos y resultado como variables locales. Así una operación no depende de la información que dejó otra. Una dependencia estable puede permanecer en el constructor; los datos propios de cada selección no necesitan convertirse en estado compartido.

3. Mover Money.add() a un servicio

Una propuesta elimina Money.add() y crea CalculationService.addMoney(), que compara moneda y suma importes. ¿Qué aporta?

Solución razonada: bajo esa descripción no aparece una responsabilidad nueva que justifique la extracción. La suma compatible es comportamiento natural de Money. Una política que decida cómo distribuir un descuento entre varias líneas podría requerir otro lugar, pero no obliga a sacar la aritmética válida del Value Object.

Criterios para elegir un Domain Service

Busca una operación de negocio que no encuentre un propietario natural entre las Entities y los Value Objects existentes. Define qué hechos necesita, qué decisión devuelve y qué transiciones deben seguir controlando sus colaboradores.

Después separa esa decisión de consultas, guardado y protocolos. Si puedes comprobar la política con entradas explícitas y escenarios del negocio, sin vaciar el resto del modelo ni ocultar dependencias, el servicio está cumpliendo una responsabilidad concreta.

Cargando conversación…

Sigue aprendiendo