Una tienda lanza una promoción: los clientes del nivel Oro tienen un 10 % de descuento en compras a partir de 100 euros. La regla necesita dos datos: el nivel del cliente y el importe de la compra. El cliente no conoce sus compras y la compra no sabe nada de niveles. Como no encaja en ninguna de las dos entidades, aparece una clase con un nombre muy acogedor:

// Counterexample: a service that accepts everything
class OrderService {
    boolean applyGoldPromotion(String purchaseId) { return false; }
    void savePurchase(String purchaseId) { }
    void sendConfirmationEmail(String purchaseId) { }
    String readMailServerPassword() { return ""; }
    void recalculateMonthlyReport() { }
}

Primero llegó la promoción. Después alguien necesitó guardar compras y «ya había un servicio». Luego el correo, la configuración y un informe. Cada método tenía su lógica, pero la clase dejó de tener una responsabilidad reconocible. El sufijo Service se convierte en un cajón donde cabe todo, la situación que el principio de responsabilidad única intenta evitar.

DDD propone distinguir tres responsabilidades que suelen acabar mezcladas en ese cajón:

  • Un servicio de dominio contiene una regla del negocio que no pertenece de forma natural a ninguna entidad.
  • Un repositorio permite recuperar y guardar agregados sin que el resto del código sepa cómo se almacenan.
  • Un servicio de aplicación coordina los pasos de un caso de uso: carga datos, pide decisiones y guarda resultados.

Vamos a separar la promoción en esas tres piezas.

Las entidades y su parte de la regla

Empezamos por lo que ya sabemos modelar, como en entidades y objetos de valor. El cliente tiene un nivel:

enum MembershipLevel {
    STANDARD,
    GOLD
}

class Customer {
    private final String id;
    private final MembershipLevel level;

    public Customer(String id, MembershipLevel level) {
        this.id = id;
        this.level = level;
    }

    public String getId() {
        return id;
    }

    public MembershipLevel getLevel() {
        return level;
    }
}

La compra conoce a qué cliente pertenece, su subtotal y si ya tiene una promoción aplicada:

class Purchase {
    private final String id;
    private final String customerId;
    private final long subtotalInCents;
    private String appliedPromotion;

    public Purchase(String id, String customerId, long subtotalInCents) {
        if (subtotalInCents < 0) {
            throw new IllegalArgumentException("Subtotal cannot be negative.");
        }
        this.id = id;
        this.customerId = customerId;
        this.subtotalInCents = subtotalInCents;
    }

    public void applyPromotion(String promotionCode) {
        if (appliedPromotion != null) {
            throw new IllegalStateException("A promotion is already applied: " + appliedPromotion);
        }
        appliedPromotion = promotionCode;
    }

    public String getId() {
        return id;
    }

    public String getCustomerId() {
        return customerId;
    }

    public long getSubtotalInCents() {
        return subtotalInCents;
    }

    public String getAppliedPromotion() {
        return appliedPromotion;
    }
}

applyPromotion() protege una regla que sí pertenece a la compra: no se pueden acumular dos promociones. La compra no necesita saber nada de clientes para comprobarlo.

Un servicio de dominio para la decisión

La elegibilidad es otra cosa: necesita mirar a la vez al cliente y a la compra. Esa decisión tiene vocabulario del negocio, «nivel Oro», «compra mínima», y merece su propia clase:

class GoldPromotionEligibility {
    private static final long MINIMUM_SUBTOTAL_IN_CENTS = 10000;

    public boolean allows(Customer customer, Purchase purchase) {
        if (!purchase.getCustomerId().equals(customer.getId())) {
            throw new IllegalArgumentException("The purchase does not belong to this customer.");
        }
        return customer.getLevel() == MembershipLevel.GOLD
                && purchase.getSubtotalInCents() >= MINIMUM_SUBTOTAL_IN_CENTS;
    }
}

La constante deja a la vista los 100 euros, expresados en céntimos. La primera comprobación evita una confusión fácil: preguntar por la compra de un cliente con los datos de otro. Después, el método aplica la regla tal como la formuló el negocio: nivel Oro y al menos 100 euros.

Fíjate en lo que no hace: no busca nada en una base de datos, no guarda la compra ni envía correos. Recibe objetos del dominio y responde a una pregunta del dominio. Por eso se puede comprobar con cuatro combinaciones de cliente e importe, sin preparar ninguna infraestructura.

Que una regla necesite dos entidades no obliga siempre a crear un servicio. Si el negocio dijera «la compra es elegible según el nivel que se le indique», podría ser un método de Purchase que recibiera el nivel. Un servicio de dominio tiene sentido cuando la decisión no es claramente responsabilidad de ninguna de las dos y tiene entidad propia en la conversación con el negocio.

Repositorios: contratos para recuperar agregados

El caso de uso necesitará cargar la compra y el cliente, y guardar la compra modificada. Esos accesos se expresan como contratos:

interface PurchaseRepository {
    Purchase findById(String purchaseId);

    void save(Purchase purchase);
}

interface CustomerRepository {
    Customer findById(String customerId);
}

Un repositorio se comporta, desde fuera, como una colección de agregados: le pides uno por su identidad y te lo devuelve; le das uno modificado y lo conserva. No dice si debajo hay una base de datos relacional, documentos o un mapa en memoria. Es el mismo papel que tenía BookRepository en Clean Architecture o un puerto de salida en la arquitectura hexagonal: el contrato pertenece al modelo y la implementación queda fuera.

Dos detalles importan. El primero: hay un repositorio por raíz de agregado, no por tabla. Si una compra tuviera líneas guardadas en otra tabla, no habría un PurchaseLineRepository, porque permitiría modificar una línea sin pasar por la raíz, saltándose las reglas que vimos en agregados e invariantes. El segundo: no hay CustomerRepository.save(), porque este caso de uso no modifica clientes. Un contrato ofrece lo que se necesita, no todas las operaciones imaginables.

Las implementaciones de este ejemplo guardan los objetos en memoria:

class InMemoryPurchaseRepository implements PurchaseRepository {
    private final Map<String, Purchase> purchasesById = new HashMap<>();

    public Purchase findById(String purchaseId) {
        Purchase purchase = purchasesById.get(purchaseId);
        if (purchase == null) {
            throw new IllegalArgumentException("Unknown purchase: " + purchaseId);
        }
        return purchase;
    }

    public void save(Purchase purchase) {
        purchasesById.put(purchase.getId(), purchase);
    }
}

class InMemoryCustomerRepository implements CustomerRepository {
    private final Map<String, Customer> customersById = new HashMap<>();

    public void add(Customer customer) {
        customersById.put(customer.getId(), customer);
    }

    public Customer findById(String customerId) {
        Customer customer = customersById.get(customerId);
        if (customer == null) {
            throw new IllegalArgumentException("Unknown customer: " + customerId);
        }
        return customer;
    }
}

findById() busca en el mapa y falla con un mensaje claro si no encuentra nada. save() guarda la compra por su identificador, sustituyendo la versión anterior si ya existía. Son implementaciones sencillas, pero hacen de verdad lo que promete el contrato. Una versión con base de datos cumpliría el mismo contrato con consultas.

El servicio de aplicación coordina

Ahora falta la pieza que recorre el caso de uso completo:

class ApplyGoldPromotion {
    private final PurchaseRepository purchases;
    private final CustomerRepository customers;
    private final GoldPromotionEligibility eligibility;

    public ApplyGoldPromotion(PurchaseRepository purchases, CustomerRepository customers,
                              GoldPromotionEligibility eligibility) {
        this.purchases = purchases;
        this.customers = customers;
        this.eligibility = eligibility;
    }

    public boolean execute(String purchaseId) {
        Purchase purchase = purchases.findById(purchaseId);
        Customer customer = customers.findById(purchase.getCustomerId());
        if (!eligibility.allows(customer, purchase)) {
            return false;
        }
        purchase.applyPromotion("GOLD-10");
        purchases.save(purchase);
        return true;
    }
}

El método se lee como una lista de pasos: cargar la compra, cargar a su cliente, preguntar si es elegible, aplicar la promoción y guardar. Ninguno de esos pasos contiene la regla de los 100 euros ni el detalle de cómo se guarda una compra. El servicio de aplicación sabe en qué orden ocurren las cosas; las reglas están en el dominio y el almacenamiento, detrás de los contratos.

Coordinar no significa no tener condiciones. Si la compra no existe, el caso de uso termina con ese error. En una aplicación real, este sería también el lugar donde empieza y se confirma la transacción, y donde se decide qué responder si el guardado entra en conflicto.

Seguir cuatro compras

InMemoryCustomerRepository customers = new InMemoryCustomerRepository();
customers.add(new Customer("CUS-1", MembershipLevel.GOLD));
customers.add(new Customer("CUS-2", MembershipLevel.STANDARD));

InMemoryPurchaseRepository purchases = new InMemoryPurchaseRepository();
purchases.save(new Purchase("P-1", "CUS-1", 12000));
purchases.save(new Purchase("P-2", "CUS-1", 9999));
purchases.save(new Purchase("P-3", "CUS-2", 15000));

ApplyGoldPromotion applyPromotion =
        new ApplyGoldPromotion(purchases, customers, new GoldPromotionEligibility());

applyPromotion.execute("P-1"); // true
purchases.findById("P-1").getAppliedPromotion(); // "GOLD-10"
applyPromotion.execute("P-2"); // false: 99.99 EUR is below the minimum
applyPromotion.execute("P-3"); // false: CUS-2 is not GOLD
applyPromotion.execute("P-1"); // IllegalStateException: A promotion is already applied: GOLD-10

P-1 es de un cliente Oro y supera los 100 euros: se aplica la promoción y la compra se guarda con ella. P-2 se queda a un céntimo del mínimo. P-3 tiene importe de sobra, pero su cliente es estándar. En los dos casos, el servicio de dominio responde false y la compra no se modifica.

El último intento vuelve a pasar la elegibilidad, porque el cliente y el importe no han cambiado, pero applyPromotion() impide acumular la promoción. Cada regla falla en la pieza que la protege: la elegibilidad en el servicio de dominio y la acumulación en la entidad.

ApplyGoldPromotion utiliza PurchaseRepository, CustomerRepository y GoldPromotionEligibility; InMemoryPurchaseRepository implementa el contrato de compras.

Las flechas continuas indican que el servicio de aplicación utiliza un tipo; la discontinua, que una clase implementa un contrato. ApplyGoldPromotion solo conoce los contratos de los repositorios. Las implementaciones en memoria aparecen únicamente al montar los objetos, igual que en los artículos anteriores.

Cuando la regla depende de otras compras

Supongamos que el negocio añade una condición: la promoción Oro solo puede usarse una vez por cliente. La regla ya no depende de una compra y su cliente, sino de todas las compras de ese cliente. Lo natural sería que el servicio de dominio recibiera también cuántas promociones lleva aplicadas, y que el repositorio ofreciera una consulta con significado del negocio, como countWithPromotion(customerId, promotionCode), en lugar de devolver todas las compras para contarlas fuera.

El recorrido seguiría siendo claro: contar, decidir, aplicar y guardar. Pero tiene un hueco. Si el cliente confirma dos compras casi a la vez, las dos peticiones cuentan cero promociones, las dos deciden que puede aplicarse y las dos guardan. La regla estaba bien escrita y aun así se ha incumplido.

Es el mismo problema que vimos con las dos copias del stock en agregados e invariantes, ahora repartido entre varios agregados. Un servicio que consulta y después guarda expresa la regla, pero no la garantiza frente a operaciones simultáneas. Para eso hace falta algo que el almacenamiento haga cumplir: una restricción única sobre cliente y promoción, o un registro por cliente que se bloquee o se versione mientras se decide. El servicio de dominio sigue siendo el lugar donde se lee la regla; la base de datos es quien impide que dos decisiones incompatibles se confirmen.

Esta distinción importa sobre todo en las reglas de unicidad: no repetir una promoción, no tener dos reservas activas para el mismo asiento o no abrir dos procesos iguales para lo mismo. Antes de escribir el servicio conviene preguntarse qué identifica exactamente «lo mismo» y quién lo protegerá cuando lleguen dos peticiones a la vez.

Otras piezas que conviene situar

Si crear un agregado exige varias comprobaciones y componentes coherentes entre sí, una factoría puede concentrar esa construcción y devolver un objeto válido o explicar por qué no puede crearlo. Si un constructor ya lo expresa con claridad, como en Purchase, no hace falta añadir otra clase.

Las pantallas de consulta tienen otras necesidades. Un listado con las compras del mes y su descuento no necesita cargar cada agregado completo: puede leer una vista preparada con los campos que muestra, siempre que no la use para modificar datos saltándose las reglas.

Y un repositorio no debería enviar correos al guardar una compra. Ese efecto quedaría escondido en una operación que parece de almacenamiento, y sería difícil saber qué pasa si el guardado se repite. Si aplicar una promoción debe avisar al cliente, el caso de uso lo deja explícito, por ejemplo registrando un hecho como «promoción aplicada» para que otra parte del sistema envíe el aviso.

Una pregunta para cada pieza

Para revisar un diseño, pregunta a cada clase qué hace. Un servicio de dominio responde «decido si esta compra es elegible». Un repositorio, «recupero y guardo compras». Un servicio de aplicación, «coordino los pasos para aplicar la promoción». Si para describir una clase necesitas enumerar promociones, correos, contraseñas e informes, como el OrderService del principio, probablemente hay varias piezas esperando a separarse.

Con la regla de la promoción en GoldPromotionEligibility, cambiar el mínimo a 120 euros es tocar una constante y revisar cuatro casos. Con la regla repartida por un servicio que lo hace todo, el mismo cambio empieza por averiguar dónde está.