Lección 8 de 912 min
Patrones de datos y mensajería
Repository, Unit of Work, Cache Aside, Pub/Sub y el patrón Outbox.
Un usuario hace un pedido. Tu código lo guarda en la base de datos, publica un mensaje para que el almacén lo prepare y borra de la caché el stock antiguo. ¿Qué pasa si el servidor se reinicia justo entre el guardado y la publicación? El pedido existe, pero el almacén nunca se entera.
Los patrones de esta lección ordenan cómo habla tu aplicación con sus datos (Repository, Unit of Work, Cache Aside) y cómo se avisan las piezas entre sí sin perder mensajes por el camino (Pub/Sub y Outbox).
Hablar con la base de datos
Repository
Una aparente colección en memoria que oculta cómo se guardan y recuperan los objetos.
Un repositorio se presenta como una colección de objetos de dominio: buscarPorEmail(), guardar(), eliminar(). Por dentro puede usar SQL, un ORM, una API o un archivo, pero a quien lo usa ni lo sabe ni le importa. La lógica de negocio habla de usuarios y pedidos, no de tablas y JOIN.
Encaja con las arquitecturas de la lección de capas: la interfaz del repositorio se define junto al dominio y la implementación concreta vive en la infraestructura. En los tests, la sustituyes por una versión en memoria. Verás cómo lo aplican los ORM en el módulo de acceso a datos.
Úsalo cuando
Cuando quieres aislar la lógica de negocio del almacenamiento, poder probarla sin base de datos o reunir en un sitio las consultas de cada tipo de objeto.
Ten en cuenta
Un repositorio genérico que solo reenvía cada método del ORM (findAll, findOne…) no aporta nada. Diseña sus métodos con el lenguaje del negocio: pedidosPendientesDeEnvio(), no findByStatus(3).
// La interfaz habla el lenguaje del dominio
export interface RepositorioUsuarios {
buscarPorEmail(email: string): Promise<Usuario | null>;
guardar(usuario: Usuario): Promise<void>;
}
// Una implementación concreta, con SQL
export class UsuariosPostgres implements RepositorioUsuarios {
constructor(private db: Pool) {}
async buscarPorEmail(email: string) {
const { rows } = await this.db.query("SELECT * FROM usuarios WHERE email = $1", [email]);
return rows[0] ? aUsuario(rows[0]) : null;
}
async guardar(usuario: Usuario) { /* INSERT … ON CONFLICT DO UPDATE */ }
}Unit of Work
Anotar todos los cambios de una operación y confirmarlos juntos, en una transacción.
- En ORM:
- EF Core, Hibernate, Doctrine, MikroORM
Una operación de negocio suele tocar varios objetos: crear un pedido, restar stock, sumar puntos al cliente. La unidad de trabajo (Unit of Work) lleva la cuenta de qué objetos se han creado, modificado o borrado durante la operación y, al final, lo escribe todo de una vez dentro de una transacción. O se guarda todo, o no se guarda nada.
Rara vez lo programas tú: lo implementan muchos ORM. El DbContext de Entity Framework, la sesión de Hibernate o el EntityManager de Doctrine y de MikroORM detectan los cambios en los objetos que cargaste y los envían juntos al llamar a SaveChanges(), flush() o su equivalente.
Úsalo cuando
En operaciones que modifican varios objetos que deben quedar coherentes, sobre todo si usas repositorios: cada uno registra sus cambios en la misma unidad de trabajo.
Ten en cuenta
Solo cubre una base de datos: si la operación también publica un mensaje o llama a otra API, eso queda fuera de la transacción (lo resuelve el patrón Outbox, más abajo). Y una unidad de trabajo abierta mucho tiempo acumula objetos en memoria y bloqueos.
export async function crearPedido(datos: DatosPedido, uow: UnidadDeTrabajo) {
const producto = await uow.productos.buscar(datos.productoId);
const cliente = await uow.clientes.buscar(datos.clienteId);
producto.restarStock(datos.cantidad); // la unidad anota el cambio
cliente.sumarPuntos(10); // y este
uow.pedidos.agregar(Pedido.crear(datos)); // y el alta
await uow.confirmar(); // BEGIN, tres escrituras, COMMIT
}Cache Aside
Busca primero en la caché; si no está, lee de la base de datos y guarda el resultado.
- También llamado:
- Lazy loading
Con cache aside (también llamado lazy loading), la aplicación gestiona la caché por su cuenta. Al leer, mira primero en la caché (Redis, por ejemplo): si el dato está, lo devuelve. Si no, lo lee de la base de datos, lo guarda en la caché con un tiempo de caducidad (TTL, Time To Live) y lo devuelve. La caché solo se llena con lo que de verdad se pide.
Al escribir, lo habitual es actualizar la base de datos y borrar la entrada de la caché en lugar de actualizarla: la siguiente lectura la volverá a cargar con el valor bueno. Lo verás con detalle en el módulo de caché, colas y mensajería.
Úsalo cuando
Para datos que se leen mucho más de lo que se escriben y que toleran estar unos segundos desactualizados: fichas de producto, perfiles, configuración.
Ten en cuenta
Hay una ventana en la que la caché puede servir datos viejos. Si muchas peticiones no encuentran el mismo dato a la vez, todas van a la base de datos y pueden saturarla (la llamada estampida o thundering herd). Y la primera lectura de cada dato siempre es lenta.
export async function obtenerProducto(id: string): Promise<Producto> {
const clave = `producto:${id}`;
const enCache = await redis.get(clave);
if (enCache) return JSON.parse(enCache); // acierto: rápido
const producto = await db.productos.buscar(id); // fallo: a la base de datos
await redis.set(clave, JSON.stringify(producto), { EX: 300 }); // 5 minutos
return producto;
}
export async function cambiarPrecio(id: string, precio: number) {
await db.productos.actualizar(id, { precio });
await redis.del(`producto:${id}`); // la próxima lectura recargará
}Mensajes que no se pierden
Pub/Sub
Los emisores publican en un tema y todos los suscriptores de ese tema reciben el mensaje.
- Herramientas:
- Kafka, RabbitMQ, Redis, SNS
En publicación/suscripción, quien envía un mensaje (el publicador) no lo dirige a nadie en concreto: lo publica en un tema (topic), como pedidos.creados. Cada servicio interesado se suscribe al tema y recibe su propia copia. Publicador y suscriptores no se conocen; solo comparten el nombre del tema y el formato del mensaje.
Es distinto de una cola de trabajo, en la que cada mensaje lo procesa un único consumidor: en pub/sub, cada mensaje llega a todos los suscriptores. Lo ofrecen Redis, Kafka, RabbitMQ (con exchanges de tipo fanout o topic), Google Cloud Pub/Sub o Amazon SNS. Es el mecanismo que hace posible la arquitectura orientada a eventos.
Úsalo cuando
Cuando un mismo hecho interesa a varios consumidores que pueden cambiar con el tiempo: notificaciones, analítica, actualizar cachés o índices de búsqueda, integraciones.
Ten en cuenta
Según la herramienta, un suscriptor desconectado puede perder mensajes (Redis Pub/Sub no los guarda) o recibirlos repetidos. Diseña consumidores idempotentes: que puedan procesar dos veces el mismo mensaje sin efectos dobles.
// El servicio de pedidos publica y se olvida
await broker.publicar("pedidos.creados", { id: "m-1", pedidoId: "p-81", total: 59.9 });
// Cada suscriptor recibe su propia copia
broker.suscribir("pedidos.creados", (m) => analitica.registrar("venta", m.total));
broker.suscribir("pedidos.creados", async (m) => {
if (await yaProcesado(m.id)) return; // idempotente: ignora duplicados
await correo.enviarConfirmacion(m.pedidoId);
await marcarProcesado(m.id);
});Outbox (bandeja de salida)
Guardar el mensaje en la misma transacción que los datos y publicarlo después.
- Entrega:
- Al menos una vez
- Relay:
- Sondeo o CDC
Primero, el problema de la doble escritura (dual write): tu código guarda un pedido en la base de datos y luego publica «PedidoCreado» en el broker. Son dos sistemas distintos y ninguna transacción abarca los dos. Si el proceso se cae entre ambos pasos, el pedido existe pero nadie se entera; si publicas primero y falla el guardado, avisas de un pedido que no existe.
El outbox transaccional lo resuelve guardando el mensaje en una tabla outbox de la misma base de datos, dentro de la misma transacción que el pedido: o se guardan los dos o ninguno. Después, un proceso aparte, el relay, lee los mensajes pendientes, los publica en el broker y los marca como enviados. Puede consultar la tabla cada poco tiempo o leer el registro de cambios de la base de datos (CDC, Change Data Capture, con herramientas como Debezium).
Úsalo cuando
Siempre que un cambio en la base de datos deba avisarse a otros servicios de forma fiable: eventos entre microservicios, integraciones, sagas.
Ten en cuenta
El relay puede publicar un mensaje y caerse antes de marcarlo como enviado, así que lo publicará de nuevo: la entrega es al menos una vez y los consumidores deben ser idempotentes. Limpia los mensajes ya enviados para que la tabla no crezca sin fin.
// 1. Pedido y mensaje, en la misma transacción
await db.transaction(async (tx) => {
await tx.insert("pedidos", pedido);
await tx.insert("outbox", { tipo: "PedidoCreado", datos: JSON.stringify(pedido) });
});
// 2. El relay, en un proceso aparte, publica lo pendiente
async function relay() {
const pendientes = await db.query(
"SELECT * FROM outbox WHERE enviado_en IS NULL ORDER BY id LIMIT 100",
);
for (const mensaje of pendientes) {
await broker.publicar(mensaje.tipo, mensaje.datos);
await db.query("UPDATE outbox SET enviado_en = now() WHERE id = $1", [mensaje.id]);
}
}
setInterval(relay, 1000);Nota
Outbox y pub/sub trabajan juntos: el outbox garantiza que el mensaje sale de tu servicio, y el broker lo reparte a todos los interesados. Al otro lado, un consumidor idempotente (por ejemplo, con una tabla de mensajes ya procesados, a veces llamada «inbox») evita procesarlo dos veces.
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Tu lógica de negocio tiene consultas SQL repartidas por todas partes y quieres probar las reglas de los pedidos sin una base de datos real.
Para recordar
- Un repositorio oculta el almacenamiento tras métodos con el lenguaje del negocio; una unidad de trabajo confirma juntos todos los cambios de una operación.
- Cache aside lee de la caché y, si no encuentra el dato, de la base de datos; al escribir, borra la entrada para no servir datos viejos.
- Pub/sub reparte cada mensaje a todos los suscriptores de un tema sin que el publicador los conozca.
- La doble escritura (base de datos y broker sin transacción común) pierde mensajes; el outbox transaccional y un relay lo evitan, con entrega al menos una vez.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.En cache aside, ¿qué se suele hacer con la caché al actualizar un dato en la base de datos?
2.¿Qué es el problema de la doble escritura?
3.¿Por qué los consumidores de los mensajes de un outbox deben ser idempotentes?
4.¿En qué se diferencia pub/sub de una cola de trabajo?
5.¿Qué método encaja mejor en un buen repositorio?