Arquitectura de software

Domain-Driven Design: lenguaje ubicuo, agregados y bounded contexts

Avanzado11 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

El domain-driven design consiste en dejar que la forma del negocio determine la forma del código, y aporta un vocabulario para negociar las fronteras entre equipos. Esta guía cubre la parte estratégica (bounded contexts, context maps, subdominios) y la parte táctica con un modelo completo en Java, y después muestra cómo la arquitectura hexagonal mantiene el dominio libre de frameworks.

Lo que aprenderás

  • Construir un lenguaje ubicuo con los expertos del dominio y mantenerlo visible en el código
  • Modelar entidades, value objects y agregados con invariantes que se cumplen en cada commit
  • Trazar bounded contexts siguiendo las fronteras del lenguaje y documentar sus relaciones en un context map
  • Publicar y consumir eventos de dominio sin filtrar el modelo interno
  • Aplicar la arquitectura hexagonal para que el dominio no dependa de ningún framework

La mayoría del software que se vuelve imposible de mantener llega a ese punto por la misma razón: el código dejó de describir el negocio. Los nombres de las clases se alejan de lo que la gente dice realmente en las reuniones, las reglas acaban en el servicio que casualmente estaba abierto en ese momento y, al cabo de dos años, la única forma fiable de responder a «¿qué pasa cuando se cancela un pedido después del envío?» es leer cuatro mil líneas. El domain-driven design es un conjunto de prácticas para evitarlo, basado en una afirmación que parece obvia y que es muy difícil de sostener: el modelo del código y el modelo que tienen en la cabeza los expertos del dominio deben ser el mismo modelo.

El DDD tiene dos mitades que suelen enseñarse juntas y adoptarse por separado. La mitad estratégica trata de las fronteras: qué partes del negocio tienen su propio modelo, cómo se relacionan esos modelos y dónde invertir un esfuerzo de diseño que siempre escasea. La mitad táctica es un conjunto de piezas para expresar un modelo en código. El DDD estratégico es donde está la mayor parte del valor, y es la parte que los equipos se saltan.

El lenguaje ubicuo

Un lenguaje ubicuo (ubiquitous language) es un vocabulario compartido por desarrolladores y expertos del dominio, que se usa de forma idéntica en las conversaciones, en la documentación y en el código. No es un glosario guardado en una wiki que nadie abre, sino un lenguaje de trabajo que cambia cuando cambia la comprensión del equipo.

La prueba práctica es sencilla. Siéntate con un experto del dominio y describe lo que hace el código usando solo las palabras que aparecen en el código. Si te descubres traduciendo («lo que nosotros llamamos TransactionRecord es lo que tú llamas instrucción de liquidación, salvo cuando es una retrocesión»), esa traducción es un impuesto permanente y una fuente permanente de defectos, porque cada traducción es una oportunidad de traducir mal.

Construir el lenguaje consiste sobre todo en escuchar las distinciones. Los expertos rara vez dicen «tenemos dos tipos de cliente»; dicen «para una empresa necesitamos el mandato antes de poder hacer el cargo». Esa frase contiene una distinción (empresa frente a particular), un concepto (el mandato) y una regla (sin mandato no hay cargo), y las tres cosas pertenecen al modelo. Técnicas como el event storming, que consiste en poner en una pared todos los eventos del dominio en orden cronológico con todo el equipo delante, existen precisamente para sacar a la luz ese tipo de frases a gran velocidad.

Una vez que el lenguaje existe, defiéndelo. Cuando un desarrollador proponga un OrderProcessor, pregunta cómo llama el negocio a esa actividad; si nadie sabe responder, la clase probablemente sea un cajón de sastre de comportamientos sin relación. Cuando un experto use una palabra que el código no tiene, eso es un hueco en el modelo, no un problema de nombres.

Las piezas tácticas

Los patrones tácticos le dan al lenguaje un lugar donde vivir.

Value object. Se define por completo por sus atributos, es inmutable y no tiene identidad: Money, IBAN, DateRange, Quantity. Dos value objects con los mismos atributos son intercambiables. Es en ellos donde la mayoría de las reglas del dominio pueden vivir con poco coste: un importe que no puede ser negativo, dos divisas que deben coincidir antes de sumarse, un intervalo cuyo final no puede preceder a su inicio. Hacerlos inmutables elimina toda una categoría de bugs de aliasing, y darles comportamiento mantiene ese comportamiento fuera de los servicios.

Entidad. Tiene una identidad que persiste a través de los cambios de estado. Dos pedidos con el mismo contenido siguen siendo dos pedidos. La igualdad se basa en el identificador, nunca en los valores de los campos.

Agregado. Un conjunto de entidades y value objects en el que una entidad se designa como raíz. Todo lo que está fuera referencia únicamente a la raíz, y la raíz es responsable de las invariantes de todo el conjunto. El agregado es la unidad de carga, guardado y bloqueo, lo que lo convierte en la frontera de consistencia transaccional y, por tanto, en una de las decisiones con más consecuencias de todo el modelo.

Repository. Una interfaz con aspecto de colección para recuperar y persistir raíces de agregado, expresada en términos del dominio: orders.findAwaitingDispatch(), no select * from orders where status = 3. La interfaz pertenece al dominio; la implementación, a la infraestructura.

Servicio de dominio. Comportamiento que pertenece al dominio pero no encaja de forma natural en ninguna entidad concreta: una transferencia entre dos cuentas, un cálculo de precios sobre varios agregados. Úsalos con moderación; un dominio lleno de servicios y vacío de comportamiento es el modelo anémico.

Factory. Encapsula la creación cuando construir un agregado válido no es trivial. A menudo es un método estático en la propia raíz.

Evento de dominio. El registro de algo significativo que ha ocurrido, con nombre en pasado: OrderDispatched, PaymentDeclined, MandateRevoked. Los eventos permiten que un agregado desencadene trabajo en otro sin quedar acoplado a él.

Un modelo completo

Las reglas: un pedido solo puede confirmarse si tiene al menos una línea y una dirección de entrega válida; no se pueden añadir líneas después de la confirmación; el total es la suma de las líneas y no debe superar el límite de crédito del cliente, que se comprueba fuera del agregado.

// Value object: immutable, self-validating, behaviour included.
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.scale() > currency.getDefaultFractionDigits())
            throw new IllegalArgumentException("too many decimals for " + currency);
    }
    public Money plus(Money other) {
        if (!currency.equals(other.currency))
            throw new CurrencyMismatch(currency, other.currency);
        return new Money(amount.add(other.amount), currency);
    }
    public boolean exceeds(Money limit) { return amount.compareTo(limit.amount) > 0; }
}

// Aggregate root: the only thing outside code may hold a reference to.
public class Order {

    private final OrderId id;
    private final CustomerId customer;
    private final List<OrderLine> lines = new ArrayList<>();
    private DeliveryAddress address;
    private OrderStatus status = OrderStatus.DRAFT;
    private final List<DomainEvent> events = new ArrayList<>();

    public void addLine(Sku sku, int quantity, Money unitPrice) {
        if (status != OrderStatus.DRAFT)
            throw new OrderAlreadyConfirmed(id);          // invariant, enforced here and nowhere else
        lines.stream().filter(l -> l.sku().equals(sku)).findFirst()
             .ifPresentOrElse(l -> l.increaseBy(quantity),
                              () -> lines.add(new OrderLine(sku, quantity, unitPrice)));
    }

    public void confirm(Clock clock) {
        if (lines.isEmpty())  throw new EmptyOrder(id);
        if (address == null)  throw new MissingDeliveryAddress(id);
        status = OrderStatus.CONFIRMED;
        events.add(new OrderConfirmed(id, customer, total(), clock.instant()));
    }

    public Money total() {
        return lines.stream().map(OrderLine::lineTotal)
                    .reduce(Money::plus).orElseThrow();
    }

    public List<DomainEvent> pullEvents() {          // drained by the application layer after save
        List<DomainEvent> drained = List.copyOf(events);
        events.clear();
        return drained;
    }
}

// Repository interface: domain vocabulary, no persistence technology.
public interface Orders {
    Optional<Order> byId(OrderId id);
    List<Order> awaitingDispatch(DeliveryZone zone);
    void save(Order order);
}

Hay tres cosas que conviene observar. Todas las reglas de un pedido viven dentro de Order, así que hay un único sitio que leer y un único sitio que cambiar. Nada externo puede acceder directamente a una OrderLine, de modo que ningún llamante puede saltarse addLine. Y no hay ni una sola anotación de framework en el dominio: el mapeo de persistencia vive en un adaptador de infraestructura, que es de lo que trata la siguiente sección.

La comprobación del límite de crédito falta a propósito en el agregado. Necesita el agregado del cliente, y cruzar agregados dentro de una misma transacción es precisamente lo que hace que crezcan hasta que todos los pedidos del sistema compiten por la misma fila de cliente. En su lugar, o bien la capa de aplicación carga ambos, o bien la comprobación se hace de forma asíncrona como reacción a OrderConfirmed, con una cancelación compensatoria si falla: la forma de saga que se explica en transacciones distribuidas y consistencia.

Bounded contexts

Un modelo único que abarque toda una empresa se hunde bajo su propio peso, porque la misma palabra significa cosas distintas para departamentos distintos. Para ventas, un «cliente» es un cliente potencial en una fase del pipeline. Para facturación, un cliente es una entidad jurídica con un identificador fiscal y un método de pago. Para soporte, un cliente es quien esté al otro lado del teléfono. Obligar a una sola clase Customer a servir a los tres produce un tipo con sesenta campos, la mayoría a null, y reglas que se contradicen entre sí.

Un bounded context (contexto delimitado) es una frontera explícita dentro de la cual un modelo y un lenguaje se aplican de forma coherente. Fuera de ella, las mismas palabras pueden significar otra cosa, y no pasa nada mientras la frontera sea explícita y la traducción se haga en ella. Un contexto tiene un equipo, una base de código, su propio almacenamiento y su propio vocabulario.

Esto convierte a los bounded contexts en la mejor guía disponible para las fronteras entre servicios. Una frontera de contexto es donde cambia el lenguaje, que es exactamente donde debe haber una interfaz, así que contextos y servicios suelen corresponderse uno a uno, aunque un contexto puede implementarse con varios servicios y, en un monolito modular, es simplemente un módulo con fronteras impuestas.

Relacionada con esto está la distinción entre tipos de subdominio, que te dice dónde invertir el esfuerzo:

SubdominioQué esInversión
Núcleo (core)Lo que diferencia al negocioLas mejores personas, el modelado más profundo, desarrollo propio
De soporte (supporting)Necesario, específico de tu empresa, pero no diferenciadorConstruirlo de forma sencilla, que sea aburrido
Genérico (generic)Problemas resueltos: identidad, contabilidad, correo electrónicoComprar o usar un servicio existente

Aplicar todo el DDD táctico a un subdominio genérico es un error habitual y caro. Nadie gana nada modelando a mano una tabla de conversión de divisas.

Context maps

En cuanto tienes varios contextos, tienes relaciones, y el tipo de relación determina cuánto duele cada cambio. Ponerles nombre hace explícita una estructura de poder que era implícita.

  • Partnership (asociación): dos equipos triunfan o fracasan juntos y coordinan sus entregas de forma deliberada.
  • Shared kernel (núcleo compartido): un modelo pequeño que dos contextos comparten a propósito y que solo puede cambiarse de común acuerdo. Mantenlo diminuto; acopla los ciclos de entrega.
  • Customer/supplier (cliente/proveedor): el equipo de aguas abajo es un cliente cuyas necesidades entran en el backlog del equipo de aguas arriba.
  • Conformist (conformista): el equipo de aguas abajo adopta tal cual el modelo de aguas arriba porque no tiene ninguna influencia. Es barato, e importa en bloque las decisiones de diseño de aguas arriba.
  • Anti-corruption layer (ACL) (capa anticorrupción): el equipo de aguas abajo construye una capa de traducción para que el modelo de aguas arriba nunca entre en el suyo. El patrón más valioso para integrarse con un sistema legacy o con un proveedor externo.
  • Open host service (servicio de host abierto): el equipo de aguas arriba publica un protocolo diseñado a propósito para muchos consumidores, en lugar de integraciones a medida.
  • Published language (lenguaje publicado): un formato de intercambio compartido y bien documentado, a menudo combinado con un open host service.
  • Separate ways (caminos separados): los contextos no se integran en absoluto, porque el coste supera al beneficio.

La anti-corruption layer merece que insistamos en ella. Cuando un servicio consume la API de un socio o un sistema de registro cuyo modelo no se parece en nada al tuyo, traducir en el borde (un adaptador que convierte su CUST_REC_TYPE_3 en tu Customer) impide que el modelo ajeno se extienda por tu código. Sin ella, dentro de un año los nombres de campo del socio estarán en tu base de datos.

Arquitectura hexagonal

Los patrones tácticos solo sobreviven si la infraestructura no puede colarse en el dominio, y eso es un problema de dirección de las dependencias. La arquitectura hexagonal (o, lo que es lo mismo, puertos y adaptadores, o las arquitecturas onion y clean) enuncia la regla sin rodeos: las dependencias apuntan hacia dentro, hacia el dominio, y el dominio no depende de nada.

  • Dominio: entidades, value objects, agregados, servicios de dominio, eventos de dominio, interfaces de repository. Sin imports de frameworks, sin SQL, sin HTTP, sin JSON.
  • Aplicación: casos de uso que orquestan el dominio: cargar un agregado, llamar a un método, guardar, publicar eventos, gestionar la frontera transaccional. Delgada por diseño.
  • Adaptadores: el mundo exterior en ambas direcciones. Los adaptadores primarios (controladores REST, listeners de mensajes, tareas programadas) llaman a la aplicación; los adaptadores secundarios (repositories JPA, clientes HTTP, publicadores en el broker) implementan interfaces que declaró el dominio.
// Application layer: one use case, transaction boundary, no business rules of its own.
@Service
class ConfirmOrder {
    private final Orders orders;              // domain port
    private final DomainEventPublisher publisher;

    @Transactional
    public void handle(OrderId id) {
        Order order = orders.byId(id).orElseThrow(() -> new UnknownOrder(id));
        order.confirm(Clock.systemUTC());     // the rule lives in the aggregate
        orders.save(order);
        publisher.publishAll(order.pullEvents());
    }
}

La ventaja práctica es la testabilidad. El dominio no tiene ningún contenedor que arrancar ni ninguna base de datos que poblar, así que sus tests se ejecutan en milisegundos y se leen como especificaciones de las reglas de negocio. Estas ideas sobre capas enlazan directamente con la arquitectura en capas y n-tier, y la inversión de dependencias que lo hace posible es el mismo mecanismo que se describe en el contenedor IoC de Spring.

Una nota pragmática sobre la persistencia. Los puristas mantienen las anotaciones JPA fuera del dominio con entidades de persistencia separadas y un mapeo entre ambas; es realmente más limpio y realmente más código. Anotar directamente las clases del dominio es un compromiso defendible para un equipo que, de otro modo, no mantendría ese mapeo, siempre que el diseño del modelo lo dicte el dominio y no lo que resulte cómodo de mapear. Lo que no es defendible es dejar que la carga lazy, la configuración de cascada o los identificadores generados decidan las fronteras de tus agregados.

Errores comunes

Patrones tácticos sin diseño estratégico. Poner @ValueObject por todas partes en un único modelo compartido gigantesco produce ceremonia sin beneficio. Primero los contextos.

El agregado dios. Un agregado Customer que contiene pedidos, facturas y tickets implica que cada operación bloquea al cliente. Divide según las invariantes y conecta mediante eventos.

Repositories que devuelven filas. Un repository que expone un lenguaje de consultas, proyecciones y paginación sobre campos arbitrarios es un DAO con un nombre de moda. Las necesidades de consulta que no encajan son legítimas: resuélvelas con un modelo de lectura aparte en lugar de deformar el agregado.

Publicar los eventos internos como contrato público. Todos los consumidores quedan acoplados a tus nombres de campo. Traduce los eventos de dominio en eventos de integración diseñados a propósito, en la frontera.

Modelar en aislamiento. Un modelo construido por desarrolladores a partir de un documento de especificación, sin un experto del dominio en la sala, codifica las ambigüedades de la especificación. El lenguaje se construye conversando o no se construye.

Puntos clave

  • El lenguaje ubicuo es la práctica central: un único vocabulario en la conversación, la documentación y el código, revisado cada vez que cambia la comprensión.
  • Los value objects llevan las reglas con poco coste, las entidades llevan la identidad y los agregados definen la frontera de consistencia: mantenlos pequeños y modifica uno por transacción.
  • Los bounded contexts legitiman que una misma palabra signifique cosas distintas en distintas partes del negocio, y son la guía más fiable para las fronteras entre servicios.
  • Un context map pone nombre a cada relación (conformist, customer/supplier, anti-corruption layer) y hace visibles el acoplamiento y el poder antes de que duelan.
  • Invierte el esfuerzo de modelado en el subdominio núcleo; compra lo genérico y mantén aburridos los subdominios de soporte.
  • La arquitectura hexagonal mantiene los frameworks fuera del dominio, y eso es lo que hace que el modelo se pueda probar en milisegundos y resista los cambios de tecnología.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿El DDD exige microservicios?
En absoluto. El DDD es anterior a los microservicios y se aplica perfectamente dentro de un único artefacto desplegable, donde los bounded contexts se convierten en módulos con fronteras impuestas. La relación funciona al revés: si vas a dividir un sistema en servicios, los bounded contexts son la mejor guía disponible para decidir dónde van las costuras, porque una frontera de contexto es justo el punto donde cambian el lenguaje y el modelo.
¿Qué tamaño debe tener un agregado?
Tan pequeño como lo permitan las invariantes. Un agregado es una frontera de consistencia que se carga y se guarda como una unidad, así que uno grande implica transacciones grandes y contención sobre la raíz. Si una regla exige de verdad que dos entidades cambien juntas de forma atómica, pertenecen al mismo agregado; si la regla tolera un pequeño retraso, sepáralas y conecta las piezas con un evento de dominio.
¿Un modelo de dominio anémico siempre es un error?
Es un error cuando el dominio tiene reglas interesantes y acaban dispersas por servicios y controladores en lugar de vivir junto a los datos que restringen. Para un CRUD realmente sencillo (datos de referencia, pantallas de configuración), un modelo ligero con una capa de servicios es lo adecuado y la ceremonia del DDD sería un desperdicio. Aplica los patrones tácticos al núcleo complejo, no de forma uniforme.
¿Qué diferencia hay entre un evento de dominio y un evento de integración?
Un evento de dominio describe algo que ocurrió dentro de un bounded context, usa el lenguaje de ese contexto y puede llevar identificadores internos. Un evento de integración es un mensaje público diseñado a propósito para otros contextos, con un esquema estable y un vocabulario que tiene sentido fuera. Publicar tus eventos de dominio tal cual en el broker compartido acopla a todos los consumidores a tu modelo interno; traduce en la frontera.