Saltar al contenido

034 — El agregado como unidad de consistencia

🗂️ parte 🎚️ nivel ⏱️ duración 📗 clase

Programa · Parte 06 · ← Anterior · Siguiente →

Parte 06 — Documentos y clave-valor · Intermedio · 3 horas estimadas · motores mongodb, redis, dynamodb · laboratorio labs/02-polyglot-modeling · 3 fuentes.

Conceptos centrales: agregado · frontera transaccional · entidad · actividad

En este caso se comparan 7 motores: 5 lo resuelven (5 con el resultado comprobado por máquina) y 2 no, con el motivo escrito.

De qué trata esta clase

El agregado como unidad de lectura, escritura y consistencia, y la consecuencia que ordena toda la parte: en los motores documentales la frontera transaccional coincide con el agregado. Diseñar el agregado es, por tanto, decidir dónde termina la garantía del motor y empieza el trabajo de la aplicación.

flowchart LR
    C["🗄️ Clase 034"]
    C --> K1["agregado"]
    C --> K2["frontera transaccional"]
    C --> K3["entidad"]
    C --> K4["actividad"]
    classDef raiz fill:#0b3d2e,stroke:#3fb950,color:#fff
    class C raiz

Antes de empezar

Esta clase supone que ya trabajaste lo siguiente. Si algo de la última columna no te suena, vuelve a esa clase antes de seguir: aquí se usa sin volver a explicarlo.

# Clase previa Lo que se da por sabido
019 Desnormalización deliberada y patrones de acceso redundancia controlada · costo de escritura · agregado · patrón de lectura
023 Integridad: restricciones, claves foraneas y acciones referenciales integridad de entidad · integridad referencial · CHECK · ON DELETE · aplazamiento

Vocabulario de la clase

Los términos que siguen se usan más adelante con este significado exacto. La definición completa, con sus términos relacionados, está en el glosario del programa.

Término Qué significa Procedencia
agregado Conjunto de datos que se trata como una unidad para leer, escribir y garantizar consistencia: un pedido con sus líneas. Sadalage y Fowler lo toman del diseño dirigido por el dominio y lo convierten en el criterio que separa a los motores NoSQL del relacional. (En la clase 027 la palabra se usa en su otro sentido: el resultado de una función de agregación como SUM o COUNT.) se introdujo en la 019
frontera transaccional El límite dentro del cual el motor garantiza atomicidad y aislamiento. En los motores de agregado coincide con el agregado: una escritura sobre un documento es atómica, dos sobre documentos distintos ya no. Diseñar el agregado es, por tanto, diseñar dónde termina la garantía. se introduce aquí
entidad Cosa del dominio con identidad propia que persiste a lo largo del tiempo: un cliente, un producto, una cuenta. Se distingue de la actividad en que existe aunque no pase nada, y suele ser la raíz de un agregado. se introduce aquí
actividad Hecho que ocurre en un instante y relaciona entidades: un pedido, un pago, una inscripción. Su volumen crece sin límite con el tiempo, lo que la convierte en la candidata natural a tabla de hechos o a flujo de eventos, y en la mala candidata a incrustarse dentro de una entidad. se introduce aquí

Propósito

Entender la idea que organiza casi todo el mundo no relacional: el agregado. Elegir sus fronteras es elegir dónde hay transacciones y dónde no.

Resultados de aprendizaje

Al terminar podrás:

  1. Definir agregado y distinguirlo de entidad y de tabla.
  2. Explicar por qué la frontera del agregado es la frontera de la atomicidad.
  3. Aplicar el criterio de Helland sobre entidades y actividades.
  4. Diseñar la coherencia entre agregados sin transacción distribuida.
  5. Reconocer cuándo un agregado mal elegido produce un punto caliente.

Fundamentos

Qué es un agregado

Sadalage y Fowler toman el término del diseño dirigido por el dominio: un agregado es un conjunto de datos que se trata como una unidad para lectura y escritura. Un pedido con sus líneas y su dirección de envío es un agregado; el cliente que lo hizo es otro.

En un modelo relacional el agregado no existe: cada entidad es su tabla y la transacción puede abarcar cuantas quiera. En los modelos documental, clave-valor y de columnas anchas, el agregado es la unidad de almacenamiento, de replicación y —esto es lo decisivo— de atomicidad.

Modelo Unidad de atomicidad garantizada
Relacional La transacción, sobre cualquier conjunto de tablas
Documental El documento; varios documentos solo con transacciones explícitas
Clave-valor La clave; operaciones multiclave solo con guiones o transacciones
Columnas anchas La partición

La consecuencia

Si dos datos deben cambiar juntos siempre, deben estar en el mismo agregado o hay que asumir explícitamente la inconsistencia transitoria. No hay tercera opción barata.

Ejemplo: «el saldo del monedero y el registro del movimiento deben cuadrar». En un solo documento, la escritura es atómica. En dos documentos, existe un instante en que uno se escribió y el otro no; si el proceso muere ahí, queda una inconsistencia que alguien debe reparar.

Entidades y actividades

Helland ofrece el marco más útil para el caso distribuido:

De ahí sus dos exigencias para cualquier interacción entre agregados: los mensajes deben ser idempotentes (procesar dos veces no cambia el resultado) y conmutativos cuando sea posible (el orden de llegada no altera el estado final).

Ese es exactamente el patrón saga de la clase 047: la actividad se descompone en pasos locales atómicos, cada uno con su compensación.

Cómo elegir la frontera

flowchart TD
    A["Dos datos relacionados"] --> B{"¿Deben cambiar<br/>juntos siempre?"}
    B -- "Sí" --> C{"¿Crecen sin<br/>límite juntos?"}
    C -- "No" --> D["Mismo agregado"]
    C -- "Sí" --> E["Agregados separados<br/>+ compensación explícita"]
    B -- "No" --> F{"¿Se leen<br/>siempre juntos?"}
    F -- "Sí" --> G{"¿Uno cambia mucho<br/>más que el otro?"}
    G -- "No" --> D
    G -- "Sí" --> H["Separar: evita reescribir<br/>lo estable en cada cambio"]
    F -- "No" --> H

Tres preguntas, en este orden: ¿cambian juntos?, ¿crecen sin límite?, ¿se leen juntos? La primera manda sobre las otras dos, porque es la única que afecta a la corrección; las otras afectan al rendimiento.

Ejemplo trabajado

Dominio: inscripciones a cursos, con la regla «el contador de inscritos del curso debe coincidir con el número de inscripciones».

Diseño A — agregado por curso:

{
  "_id": "curso-2026-1-bd",
  "nombre": "Bases de datos",
  "periodo": "2026-1",
  "cupo": 40,
  "inscritos": 3,
  "inscripciones": [
    {"student_id": 11, "nota": 6.0},
    {"student_id": 12, "nota": null},
    {"student_id": 13, "nota": 5.5}
  ]
}

Inscribir a alguien es una escritura atómica: se añade al arreglo y se incrementa el contador en la misma operación. La invariante no puede romperse.

Problemas, con números:

Diseño B — agregado por inscripción:

{"_id": "11:curso-2026-1-bd", "student_id": 11, "course_id": "curso-2026-1-bd",
 "nota": 6.0, "estado": "activa", "registrada_en": "2026-03-11T12:00:00Z"}

Escala en escritura y permite indexar por estudiante y por curso. A cambio, el contador de inscritos ya no puede mantenerse atómicamente: está en otro documento.

Las tres respuestas honestas a ese hueco:

Respuesta Garantía Costo
Calcular contando al leer Exacta siempre Una agregación por lectura
Transacción multidocumento Exacta Coordinación; en clúster, latencia y contención
Contador eventual + reconciliación Aproximada entre reconciliaciones Barata; exige la invariante auditada

Diseño C — híbrido, el habitual en producción:

{"_id": "curso-2026-1-bd", "nombre": "Bases de datos", "cupo": 40,
 "inscritos_aprox": 3812, "actualizado_en": "2026-03-11T12:00:05Z"}

Las inscripciones son documentos propios; el curso guarda un contador declaradamente aproximado. El nombre del campo comunica su semántica: quien lo lee sabe que no es una verdad transaccional. Para el control de cupo, que sí exige exactitud, se cuenta de verdad en el momento crítico.

La invariante, obligatoria en B y C:

db.enrollments.aggregate([
  {$match: {estado: "activa"}},
  {$group: {_id: "$course_id", real: {$sum: 1}}},
  {$lookup: {from: "courses", localField: "_id", foreignField: "_id", as: "c"}},
  {$unwind: "$c"},
  {$match: {$expr: {$ne: ["$real", "$c.inscritos_aprox"]}}}
])

Cero resultados: coherente. Con resultados: la divergencia, cuantificada.

Comparación

Diseño Atomicidad de la invariante Escala en escritura Consulta por estudiante Tamaño acotado
A: agregado por curso Total Mala (punto caliente) Mala No
B: agregado por inscripción Ninguna Buena Buena
C: híbrido Aproximada, declarada Buena Buena
Relacional normalizado Total (transacción) Buena Buena

La última fila merece atención: el modelo relacional no obliga a elegir entre atomicidad y escalabilidad de escritura mientras quepa en un nodo. Renunciar a él tiene sentido cuando ya no cabe, no antes.

Errores frecuentes

  1. Agregados que crecen sin límite. Toda lista dentro de un documento necesita una cota conocida.
  2. Suponer atomicidad entre documentos. No existe salvo que se pida explícitamente.
  3. Elegir el agregado por cómo se lee, ignorando cómo se escribe. El punto caliente aparece después.
  4. Contadores sin marcar como aproximados. Quien los lee supone exactitud.
  5. Copiar el modelo relacional a documentos. Una colección por tabla con referencias reproduce las reuniones sin tener el motor que las optimiza.
  6. Usar transacciones multidocumento como si fuesen gratis. En clúster tienen un costo de coordinación real.

De la clase a la operación

El punto caliente por agregado demasiado grande no se ve en desarrollo: aparece el día de mayor tráfico, que es el peor día para descubrirlo. Estimar el tamaño máximo del agregado y su tasa de escritura es parte del diseño, no una optimización posterior.

Reto de transferencia

  1. Elige una entidad de tu dominio y propón dos fronteras de agregado distintas.
  2. Para cada una, escribe la invariante que se garantiza atómicamente y la que no.
  3. Estima el tamaño máximo del agregado y la tasa de escritura sobre el más caliente.
  4. Diseña la reconciliación para la invariante que quedó fuera y su periodicidad.

Preguntas de evaluación

  1. ¿Por qué la frontera del agregado es la frontera de la atomicidad?
  2. Da un agregado de tu dominio que crecería sin límite y propón cómo acotarlo.
  3. Explica el criterio de Helland de idempotencia con un mensaje concreto de tu sistema.
  4. ¿En qué caso concreto renunciarías al modelo relacional por uno de agregados, y con qué evidencia?

🌐 El mismo problema en cada motor

Caso: Un pedido y sus líneas que cambian juntos o no cambian

Un agregado, en el sentido de Evans y de Vernon, es el grupo de datos que se trata como una unidad: tiene una raíz —el pedido—, un límite —sus líneas— y un invariante que debe cumplirse siempre. Aquí el invariante es que el total guardado sea igual a la suma de las líneas.

El caso crea el pedido con dos líneas, añade una tercera y sube el total. La consulta devuelve el total guardado y el calculado. Que coincidan es todo el ejercicio: lo interesante no es el número, sino qué mecanismo garantiza que nunca se separen en cada motor, y qué pasa cuando el agregado no cabe en la unidad atómica que ese motor ofrece.

Salida esperada, idéntica en todos los motores que lo resuelven:

pedido total_guardado total_calculado
P-1 300 300

El contrato vive en motores.yaml y lo comprueba python scripts/verificar_equivalencia.py --clase 034: 5 de las 5 implementaciones se ejecutan de verdad y su resultado se compara con esa tabla; el resto se declara como material revisado, no ejecutado.

Motor ¿Resuelve el caso? Nivel de prueba Código Fuente
SQLite núcleo código doc oficial
DuckDB núcleo código doc oficial
PostgreSQL servicio código doc oficial
MongoDB servicio código doc oficial
Redis servicio código doc oficial
Amazon DynamoDB no doc oficial
Apache Cassandra no doc oficial

Los que resuelven el caso

SQLite · implementaciones/sqlite/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: sqlite
-- doc: https://sqlite.org/lang_transaction.html
-- nota: el limite del agregado es una CONVENCION, no una propiedad del
--       esquema. Nada impide un UPDATE suelto que rompa el invariante.

-- === preparacion ===
CREATE TABLE pedidos (
    id    TEXT PRIMARY KEY,
    total INTEGER NOT NULL
);
CREATE TABLE lineas (
    pedido_id TEXT NOT NULL REFERENCES pedidos(id),
    producto  TEXT NOT NULL,
    importe   INTEGER NOT NULL,
    PRIMARY KEY (pedido_id, producto)
);

-- El agregado nace entero, dentro de UNA transaccion.
BEGIN;
INSERT INTO pedidos (id, total) VALUES ('P-1', 200);
INSERT INTO lineas (pedido_id, producto, importe) VALUES ('P-1', 'teclado', 120);
INSERT INTO lineas (pedido_id, producto, importe) VALUES ('P-1', 'raton', 80);
COMMIT;

-- Y cambia entero: la linea nueva y el total suben juntos o no sube ninguno.
BEGIN;
INSERT INTO lineas (pedido_id, producto, importe) VALUES ('P-1', 'cable', 100);
UPDATE pedidos SET total = total + 100 WHERE id = 'P-1';
COMMIT;

-- === consulta ===
-- El invariante del agregado: el total guardado y la suma de sus lineas. Si
-- alguna vez dejan de coincidir, la transaccion no estaba haciendo su trabajo.
SELECT p.id AS pedido,
       p.total AS total_guardado,
       (SELECT SUM(l.importe) FROM lineas l WHERE l.pedido_id = p.id) AS total_calculado
FROM pedidos p
ORDER BY p.id;

DuckDB · implementaciones/duckdb/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: duckdb
-- doc: https://duckdb.org/docs/stable/sql/statements/transactions.html

-- === preparacion ===
CREATE TABLE pedidos (
    id    VARCHAR PRIMARY KEY,
    total INTEGER NOT NULL
);
CREATE TABLE lineas (
    pedido_id VARCHAR NOT NULL,
    producto  VARCHAR NOT NULL,
    importe   INTEGER NOT NULL,
    PRIMARY KEY (pedido_id, producto)
);

-- El agregado nace entero, dentro de UNA transaccion.
BEGIN;
INSERT INTO pedidos (id, total) VALUES ('P-1', 200);
INSERT INTO lineas (pedido_id, producto, importe) VALUES ('P-1', 'teclado', 120);
INSERT INTO lineas (pedido_id, producto, importe) VALUES ('P-1', 'raton', 80);
COMMIT;

-- Y cambia entero: la linea nueva y el total suben juntos o no sube ninguno.
BEGIN;
INSERT INTO lineas (pedido_id, producto, importe) VALUES ('P-1', 'cable', 100);
UPDATE pedidos SET total = total + 100 WHERE id = 'P-1';
COMMIT;

-- === consulta ===
-- El invariante del agregado: el total guardado y la suma de sus lineas. Si
-- alguna vez dejan de coincidir, la transaccion no estaba haciendo su trabajo.
SELECT p.id AS pedido,
       p.total AS total_guardado,
       (SELECT SUM(l.importe) FROM lineas l WHERE l.pedido_id = p.id) AS total_calculado
FROM pedidos p
ORDER BY p.id;

PostgreSQL · implementaciones/postgresql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: postgresql
-- doc: https://www.postgresql.org/docs/current/tutorial-transactions.html
-- nota: el invariante se puede llevar al esquema con una restriccion diferible
--       comprobada al COMMIT, en vez de dejarlo en manos de quien escriba.

-- === preparacion ===
DROP TABLE IF EXISTS lineas, pedidos;

CREATE TABLE pedidos (
    id    text PRIMARY KEY,
    total integer NOT NULL
);
CREATE TABLE lineas (
    pedido_id text NOT NULL REFERENCES pedidos(id),
    producto  text NOT NULL,
    importe   integer NOT NULL,
    PRIMARY KEY (pedido_id, producto)
);

-- El agregado nace entero, dentro de UNA transaccion.
BEGIN;
INSERT INTO pedidos (id, total) VALUES ('P-1', 200);
INSERT INTO lineas (pedido_id, producto, importe) VALUES ('P-1', 'teclado', 120);
INSERT INTO lineas (pedido_id, producto, importe) VALUES ('P-1', 'raton', 80);
COMMIT;

-- Y cambia entero: la linea nueva y el total suben juntos o no sube ninguno.
BEGIN;
INSERT INTO lineas (pedido_id, producto, importe) VALUES ('P-1', 'cable', 100);
UPDATE pedidos SET total = total + 100 WHERE id = 'P-1';
COMMIT;

-- === consulta ===
-- El invariante del agregado: el total guardado y la suma de sus lineas. Si
-- alguna vez dejan de coincidir, la transaccion no estaba haciendo su trabajo.
SELECT p.id AS pedido,
       p.total AS total_guardado,
       (SELECT SUM(l.importe) FROM lineas l WHERE l.pedido_id = p.id) AS total_calculado
FROM pedidos p
ORDER BY p.id;

MongoDB · implementaciones/mongodb/consulta.js

verificado — se ejecuta contra el motor real levantado con docker compose

// motor: mongodb
// doc: https://www.mongodb.com/docs/manual/core/write-operations-atomicity/
// nota: aqui NO hay transaccion, y no hace falta. El pedido y sus lineas son un
//       solo documento, y la escritura de un documento es atomica: $push y $inc
//       en la misma orden se aplican juntos o no se aplica ninguno.

// === preparacion ===
db.pedidos.drop();

db.pedidos.insertOne({
  _id: "P-1",
  total: 200,
  lineas: [
    { producto: "teclado", importe: 120 },
    { producto: "raton", importe: 80 },
  ],
});

// Una sola orden: la linea nueva y el total suben juntos.
db.pedidos.updateOne(
  { _id: "P-1" },
  {
    $push: { lineas: { producto: "cable", importe: 100 } },
    $inc: { total: 100 },
  },
);

// === consulta ===
db.pedidos
  .aggregate([
    { $project: { _id: 0, pedido: "$_id", total_guardado: "$total",
                  total_calculado: { $sum: "$lineas.importe" } } },
    { $sort: { pedido: 1 } },
  ])
  .forEach((d) => print(d.pedido + "|" + d.total_guardado + "|" + d.total_calculado));

Redis · implementaciones/redis/consulta.txt

verificado — se ejecuta contra el motor real levantado con docker compose

# motor: redis
# doc: https://redis.io/docs/latest/develop/programmability/eval-intro/
# nota: el script Lua es la unidad atomica: se ejecuta entero, sin que ninguna
#       otra orden se cuele en medio. Es lo que permite que la linea nueva y el
#       total suban juntos. Lo que NO garantiza es durabilidad: atomico y
#       durable son dos propiedades distintas.

# === preparacion ===
FLUSHDB
HSET pedido:P-1 total 200
HSET pedido:P-1:linea teclado 120
HSET pedido:P-1:linea raton 80

# Anadir la linea y subir el total, sin estado intermedio observable.
EVAL "redis.call('HSET','pedido:P-1:linea','cable',100) redis.call('HINCRBY','pedido:P-1','total',100) return 1" 0

# === consulta ===
EVAL "local t=redis.call('HGET','pedido:P-1','total') local ls=redis.call('HVALS','pedido:P-1:linea') local s=0 for _,v in ipairs(ls) do s=s+tonumber(v) end return {'P-1|'..t..'|'..s}" 0

Los que no resuelven este caso — y qué se hace en su lugar

Descartar un motor con un argumento es tan formativo como usarlo. Ninguna de estas filas dice que el motor sea peor: dice que este problema no es el suyo.

Motor Por qué no Qué se hace en su lugar Fuente
Amazon DynamoDB Un elemento es la unidad atómica, y está limitado a 400 KB. Un agregado que no quepa ahí necesita TransactWriteItems, que admite como mucho 100 elementos, cuesta el doble de capacidad de escritura y no admite dos operaciones sobre el mismo elemento. Diseñar el agregado para que quepa en un elemento, o repartirlo en la misma clave de partición y aceptar que la coherencia entre sus elementos la comprueba un proceso posterior. doc
Apache Cassandra BATCH no es una transacción: no hay aislamiento entre particiones y no se puede deshacer. Solo dentro de una partición la escritura por lotes es atómica y aislada; en cuanto el agregado toca dos particiones, se puede observar a medias. Modelar el agregado entero dentro de una sola partición —el pedido como clave, las líneas como filas de agrupamiento— y usar BATCH únicamente dentro de ella. doc

Laboratorio

python scripts/validate_repository.py
# labs/02-polyglot-modeling se entrega escrito: no hay guion que ejecutar

Guarda como evidencia la salida completa, la versión del motor y la semilla o los parámetros usados. Una captura sin comando no es evidencia: no se puede repetir.

Evaluación

Criterio Peso Qué se comprueba
Comprensión conceptual 25 % Explica el mecanismo, no solo el resultado
Ejecución reproducible 25 % Otra persona obtiene lo mismo con las instrucciones dadas
Interpretación basada en evidencia 25 % Cada conclusión se apoya en una salida o una medición
Límites y riesgos declarados 25 % Dice qué no demuestra el ejercicio y qué faltaría en producción

La clase se da por superada cuando la respuesta explica el mecanismo, muestra la salida que la respalda y declara al menos un límite del ejercicio.

Fuentes de esta clase

Todo lo afirmado arriba procede de estas obras. Los identificadores viven en catalog/sources.json y el estado de los enlaces se comprueba con python scripts/check_external_links.py.


Programa · Parte 06 · ← Anterior · Siguiente →