Queda un ejemplar de un libro en el almacén. Ana lo añade a su compra y, un segundo después, Bruno hace lo mismo. Tenemos una clase para el producto, otra para la reserva y otra para el pedido, y todas funcionan bien por separado. Pero falta responder a la pregunta importante: ¿quién garantiza que ese ejemplar no se reserva dos veces?
En DDD, esa responsabilidad tiene nombre. Una invariante es una regla que debe cumplirse siempre al terminar una operación, sin excepciones. Un agregado es un grupo de objetos que se modifica como una unidad para proteger sus invariantes, con una raíz: el único objeto por el que se puede entrar a hacer cambios.
Vamos a construir el agregado que protege el stock de un producto y a ver qué parte del trabajo hace el objeto y qué parte necesita del almacenamiento.
Empezar por la regla que no puede romperse
La invariante del ejemplo es esta: las unidades reservadas de un producto nunca superan las que hay en el almacén. Si hay dos ejemplares, puede haber dos reservas de uno o una de dos, pero nunca tres unidades reservadas.
Escribir la regla antes que el código obliga a preguntarse qué información necesita quien la protege. Aquí basta con saber cuántas unidades hay y qué reservas existen. No hace falta el título del libro, su precio ni la historia de ventas del último año.
Esto ayuda a evitar un error frecuente: confundir el agregado con todo lo que está relacionado en la base de datos. El agregado no es el árbol de objetos que somos capaces de cargar, sino la frontera mínima que necesitamos para decidir sin romper la regla. La referencia de DDD de Eric Evans lo resume así: la raíz controla el acceso al agregado, y nadie modifica sus piezas interiores sin pasar por ella.
La raíz que protege el stock
class ProductStock {
private final String sku;
private final int unitsInWarehouse;
private final Map<String, Integer> reservations;
private final int version;
public ProductStock(String sku, int unitsInWarehouse) {
this(sku, unitsInWarehouse, new HashMap<String, Integer>(), 0);
}
ProductStock(String sku, int unitsInWarehouse, Map<String, Integer> reservations, int version) {
if (unitsInWarehouse < 0) {
throw new IllegalArgumentException("Units cannot be negative.");
}
this.sku = sku;
this.unitsInWarehouse = unitsInWarehouse;
this.reservations = new HashMap<>(reservations);
this.version = version;
if (available() < 0) {
throw new IllegalArgumentException("Reservations exceed the units in the warehouse.");
}
}
public void reserve(String reservationId, int quantity) {
if (quantity < 1) {
throw new IllegalArgumentException("Quantity must be positive.");
}
if (reservations.containsKey(reservationId)) {
throw new IllegalStateException("Reservation already exists: " + reservationId);
}
if (quantity > available()) {
throw new IllegalStateException("Not enough stock for " + sku + ".");
}
reservations.put(reservationId, quantity);
}
public void release(String reservationId) {
Integer released = reservations.remove(reservationId);
if (released == null) {
throw new IllegalStateException("Unknown reservation: " + reservationId);
}
}
public int available() {
int reserved = 0;
for (int quantity : reservations.values()) {
reserved += quantity;
}
return unitsInWarehouse - reserved;
}
public String getSku() {
return sku;
}
public int getUnitsInWarehouse() {
return unitsInWarehouse;
}
public Map<String, Integer> getReservations() {
return new HashMap<>(reservations);
}
public int getVersion() {
return version;
}
}
Las reservas se guardan en un mapa: la clave es el identificador de la reserva y el valor, las unidades reservadas. Veamos cómo protege la clase su invariante:
- No hay forma de saltarse la regla. No existe un
setReserved()que acepte cualquier número. Las únicas formas de cambiar las reservas sonreserve()yrelease(), y las dos comprueban sus condiciones antes de modificar nada. - Cada reserva tiene identidad. Liberar pide el identificador de la reserva, no un número de unidades. Así, cancelar dos veces la misma reserva no puede liberar el doble: la segunda vez el identificador ya no existe.
- El interior no se escapa.
getReservations()devuelve una copia. Quien la modifique cambiará su copia, no el mapa de la raíz. - La regla también se cumple al reconstruir. El segundo constructor, sin
public, sirve para recuperar un stock guardado con sus reservas y su versión. Aun así, comprueba al final que las reservas no superen las unidades: ni siquiera unos datos corruptos pueden crear un agregado que incumpla la invariante.
El campo version todavía no hace nada en esta clase. Lo necesitaremos enseguida.
Seguir unas cuantas reservas
ProductStock stock = new ProductStock("BOOK-01", 2);
stock.reserve("R-1", 2);
stock.available(); // 0
stock.reserve("R-2", 1); // IllegalStateException: Not enough stock for BOOK-01.
stock.release("R-1");
stock.available(); // 2
stock.release("R-1"); // IllegalStateException: Unknown reservation: R-1
La primera reserva ocupa los dos ejemplares. La segunda falla antes de modificar el mapa, así que el stock sigue en un estado válido. Al liberar R-1 vuelven a quedar dos unidades, y repetir esa liberación falla en lugar de dejar el stock en cuatro unidades disponibles de un almacén que solo tiene dos.
La regla del objeto no basta cuando hay dos copias
Mientras todas las peticiones usen el mismo objeto, una detrás de otra, la invariante está protegida. Pero en una aplicación real, Ana y Bruno son dos peticiones distintas que pueden ejecutarse a la vez. Cada una carga el stock desde la base de datos y obtiene su propia copia. Las dos ven un ejemplar disponible, las dos reservan con éxito en su copia y las dos intentan guardar.
Las dos decisiones son correctas según la información que tenía cada copia. El problema es que se tomaron sobre una información que ya no era actual. Por eso el almacenamiento tiene que colaborar: solo debe aceptar un cambio si nadie modificó el stock desde que se cargó. Ahí entra la versión.
Para verlo sin una base de datos, este repositorio en memoria imita su comportamiento: cada carga devuelve una copia nueva, como haría una consulta, y cada guardado comprueba la versión.
class InMemoryStockRepository {
private final Map<String, ProductStock> stored = new HashMap<>();
public void add(ProductStock stock) {
stored.put(stock.getSku(), copyOf(stock, stock.getVersion()));
}
public ProductStock load(String sku) {
ProductStock stock = stored.get(sku);
if (stock == null) {
throw new IllegalArgumentException("Unknown product: " + sku);
}
return copyOf(stock, stock.getVersion());
}
public void save(ProductStock stock) {
ProductStock current = stored.get(stock.getSku());
if (current.getVersion() != stock.getVersion()) {
throw new IllegalStateException("Stock " + stock.getSku() + " changed since it was loaded.");
}
stored.put(stock.getSku(), copyOf(stock, stock.getVersion() + 1));
}
private ProductStock copyOf(ProductStock stock, int version) {
return new ProductStock(stock.getSku(), stock.getUnitsInWarehouse(), stock.getReservations(), version);
}
}
save() compara la versión de la copia que llega con la versión guardada. Si coinciden, nadie ha cambiado el stock entretanto: guarda la copia y aumenta la versión. Si no coinciden, rechaza el cambio.
Ahora, la carrera por el último ejemplar:
InMemoryStockRepository repository = new InMemoryStockRepository();
repository.add(new ProductStock("BOOK-01", 1));
ProductStock anaCopy = repository.load("BOOK-01"); // version 0, available 1
ProductStock brunoCopy = repository.load("BOOK-01"); // version 0, available 1
anaCopy.reserve("R-ANA", 1);
repository.save(anaCopy); // saved as version 1
brunoCopy.reserve("R-BRUNO", 1); // valid in Bruno's copy
repository.save(brunoCopy); // IllegalStateException: Stock BOOK-01 changed since it was loaded.
repository.load("BOOK-01").available(); // 0
Las dos copias empiezan en la versión 0. Ana guarda primero, y el stock pasa a la versión 1. La copia de Bruno sigue en la versión 0, así que su guardado se rechaza aunque su reserva fuera válida dentro de su copia. Si Bruno vuelve a cargar, verá cero unidades disponibles y reserve() le dirá que no hay stock. El ejemplar se ha reservado una sola vez.

Las flechas muestran el recorrido de una modificación. El último paso es el decisivo: una decisión tomada sobre una copia antigua no se confirma. En una base de datos real, lo mismo se consigue guardando con una condición del tipo «actualiza solo si la versión sigue siendo 0», con un bloqueo de la fila durante la operación o con una actualización atómica. El objeto expresa la regla; el almacenamiento garantiza que no se confirmen dos decisiones incompatibles. Las dos partes forman el diseño.
¿Cuánto debe abarcar un agregado?
Podríamos meter todo en un gran objeto Shop con productos, clientes, pedidos y reservas. Cualquier regla tendría sus datos a mano. Pero cualquier cambio necesitaría cargar y versionar ese objeto enorme, y dos clientes comprando productos distintos entrarían en conflicto sin motivo.
Al proteger el stock por producto, las compras de BOOK-01 compiten entre sí y las de otros productos avanzan sin molestarse. Esa es la razón práctica para mantener los agregados pequeños: cuanto menor es la frontera, menos operaciones chocan.
Entre agregados, las referencias se hacen por identificador. Un pedido guarda el sku del libro y el precio acordado en el momento de comprar, no una referencia al ProductStock. Si el pedido contuviera el objeto de stock, cualquiera podría modificarlo a través del pedido saltándose su raíz. Y guardar el precio acordado evita otro problema: si el precio cambia mañana, el pedido de hoy debe seguir explicando lo que se cobró. Es la misma idea que copiaba el precio en la matrícula del monolito modular.
Hay otro motivo frecuente para querer partir un agregado: que cargarlo o guardarlo sea lento, por ejemplo porque arrastra un historial largo. Conviene separar dos preguntas. La primera es de negocio: ¿alguna invariante necesita ese historial para decidir? Si ninguna lo necesita, puede vivir fuera del agregado y hacer referencia a él por su identificador. La segunda es técnica: quizá el problema está en cómo se guarda, por ejemplo si cada guardado borra y vuelve a escribir todas las filas de una colección. Eso se arregla en el adaptador de persistencia, sin mover ninguna frontera. La frontera de un agregado se decide por sus reglas; usarla para resolver un problema de rendimiento puede acabar separando datos que una regla necesitaba juntos.
Una regla, una decisión inmediata
Una recomendación habitual es modificar un solo agregado por transacción. Reduce los conflictos, pero no sirve para esquivar una regla. Si un pedido con tres libros tiene que reservarlos todos o ninguno, hay que diseñar esa garantía: una transacción que incluya los tres stocks, o un proceso que reserve uno a uno y libere los anteriores si alguno falla. Cada opción tiene costes distintos, y la elección depende de lo que necesite el negocio.
Separar agregados tampoco obliga a usar eventos, consistencia eventual ni servicios distintos. En una aplicación con una sola base de datos, una transacción local que modifica dos agregados puede ser una decisión razonable cuando una regla del negocio lo exige. Lo importante es que sea una decisión consciente y documentada, no un efecto secundario de cómo se cargaron los objetos.
También hay datos que pueden actualizarse un poco más tarde sin romper nada. Un panel con las ventas del día puede ir unos segundos por detrás: su retraso no reserva ningún ejemplar de más. Lo que no puede hacer la reserva es decidir consultando ese panel, porque podría estar desactualizado. Las decisiones que protegen una invariante consultan al agregado; las lecturas cómodas pueden usar otras copias.
Revisar la frontera con escenarios
Para comprobar un agregado, intenta romper su invariante con operaciones concretas: reservar más de lo disponible, liberar una reserva que no existe, liberar dos veces la misma. Si el objeto resiste las tres, la regla está bien protegida dentro de él. Son pruebas pequeñas y rápidas, del tipo que describía el artículo sobre calidad y pruebas.
Después prueba lo que el objeto solo no puede demostrar: dos copias cargadas a la vez que intentan reservar la última unidad. Esa comprobación necesita el almacenamiento real o, como aquí, uno que imite su comportamiento. Un buen diseño permite señalar las dos piezas: dónde vive la regla y qué mecanismo impide confirmar dos decisiones que no pueden ser ciertas a la vez.