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.
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:
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.
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:
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.
Diseña un OrderRepository independiente del almacenamiento y compáralo con un DAO, un ORM y un Application Service. Implementa adaptadores en memoria y SQLite, recupera Aggregates completos y comprueba guardado, conflictos de versión y rollback con pruebas ejecutables.
Aprende cuándo usar un constructor, un método estático o una Factory dedicada para crear objetos de dominio completos. Construye un pedido desde una cotización en TypeScript, comprueba sus invariantes y distingue una creación nueva de la reconstitución de un estado persistido.
Diseña un Order Aggregate con una raíz que controla sus líneas y protege reglas comunes. Con TypeScript y pruebas ejecutables, aprende a elegir límites de consistencia, evitar cambios parciales y distinguir las garantías del modelo de las que requiere la persistencia transaccional.