Ya sabemos qué contextos hay en TechCorp y qué guarda cada uno de "producto" y de "cliente". Falta la decisión que más cuesta tomar y la que más consecuencias tiene: separar físicamente los datos. Mientras los seis servicios sigan leyendo y escribiendo la única base de datos techcorp, no habrá autonomía real: cualquier cambio de esquema será una negociación entre equipos, cualquier consulta pesada del catálogo bloqueará una migración de pedidos y el "microservicio" será un monolito distribuido con más latencia.
Esta lección desarrolla el patrón database-per-service: por qué la base de datos compartida es el antipatrón principal, qué grados de aislamiento existen (esquemas separados, instancias separadas, tecnologías distintas), cómo se rompen las claves foráneas cruzadas del esquema de 01-05, qué se hace con las consultas que hoy son un JOIN, quién es dueño de cada dato, cómo se migran los datos sin parar la tienda y, como resultado, qué esquema concreto tendrá cada servicio de TechCorp (SQL de servicio-pedidos y servicio-inventario, documento del catálogo en MongoDB). Cerramos adelantando la cuestión del reporting. La consistencia entre esas bases de datos separadas (sagas, CQRS, event sourcing) es el tema de la lección siguiente.
Contenido
- El patrón database-per-service y el antipatrón de la base de datos compartida
- Grados de aislamiento: del esquema separado a la persistencia políglota
- Romper las claves foráneas cruzadas
- Las consultas que antes eran un
JOIN - Propiedad y ciclo de vida de los datos
- Migrar los datos sin parar la tienda
- El esquema resultante de cada servicio de TechCorp
- Implicaciones para reporting y analítica
- El patrón database-per-service y el antipatrón de la base de datos compartida
El patrón se enuncia en una línea: cada servicio es el único que accede a su almacenamiento; los demás solo llegan a esos datos a través de su API o de sus eventos. "Almacenamiento" incluye tablas, colecciones, índices, colas privadas y ficheros. Es la materialización del principio "datos descentralizados" de 02-01 y del "contrato como única puerta".
Por qué la base de datos compartida es el antipatrón principal (y no uno más):
| Con base de datos compartida | Consecuencia | Problema de TechCorp que reproduce |
|---|---|---|
| Cualquier servicio puede leer cualquier tabla. | El esquema de todos es contrato de todos: nadie puede cambiar una columna sin auditar a los demás. | Notificaciones rompió un informe de Pedidos al cambiar una consulta. |
| Cualquier servicio puede escribir cualquier tabla. | Los invariantes de un contexto los "protege" código repartido en varios servicios. | crearPedido escribe en stock y pagos. |
| Una migración de esquema bloquea a todos. | El despliegue de un servicio depende del estado de la BD de los demás. | La migración de lineas_pedido falló por una consulta de Catálogo. |
| Un servicio con consultas pesadas degrada la BD de todos. | No hay aislamiento de rendimiento. | Black Friday: el catálogo tumbó los cobros. |
| Una única tecnología para todos. | El modelo de datos se fuerza para caber en la BD elegida. | atributos_producto clave-valor en PostgreSQL. |
En resumen: con la base de datos compartida se cumplen todos los acoplamientos de 02-01 (de datos, de despliegue, temporal) a la vez. Es por eso que la lección 01-01 lo definió como "lo que un microservicio no es".
Lo que se paga a cambio, y que el resto de la lección gestiona: se pierden los JOIN entre contextos, se pierden las claves foráneas cruzadas y se pierde la transacción única.
- Grados de aislamiento: del esquema separado a la persistencia políglota
Separar los datos no es todo o nada. Hay una escalera, y subir peldaño a peldaño es una estrategia de migración legítima:
| Peldaño | Qué se separa | Qué aísla | Qué no aísla | Uso en TechCorp |
|---|---|---|---|---|
| 0. Tablas privadas por convención | Nada físico; solo la regla "no toques tablas ajenas". | El acoplamiento de escritura, si todos la respetan. | Nada técnicamente: un JOIN sigue siendo posible. |
Primer paso dentro del monolito (02-02, refactorización previa). |
| 1. Esquemas separados en la misma instancia | Un SCHEMA de PostgreSQL por servicio (pedidos.*, inventario.*), con usuarios distintos y permisos que impiden leer esquemas ajenos. |
Acoplamiento de datos y de despliegue de esquema (cada servicio migra el suyo). | Rendimiento (misma CPU, disco y conexiones), disponibilidad (misma instancia), copias de seguridad. | Paso intermedio para clientes, inventario, pedidos y pagos durante la migración. |
| 2. Instancias separadas | Un servidor (o clúster) de base de datos por servicio. | Todo lo anterior más rendimiento y disponibilidad. | Nada relevante; coste operativo mayor. | Objetivo para pedidos e inventario en cuanto la migración avance; en Kubernetes, un StatefulSet o servicio gestionado por BD (05-02). |
| 3. Tecnologías distintas (polyglot persistence) | Cada servicio elige el tipo de almacén más adecuado. | Además, el modelo de datos deja de forzarse. | Aumenta la variedad operativa: más cosas que saber operar. | Catálogo en MongoDB; el resto en PostgreSQL. |
flowchart LR
subgraph HOY[Hoy: una instancia, un esquema]
M[(techcorp<br/>public.*)]
end
subgraph PASO1[Paso intermedio: una instancia, esquemas y usuarios separados]
P1[(pg-techcorp)]
P1 --- S1[schema pedidos<br/>usuario svc_pedidos]
P1 --- S2[schema inventario<br/>usuario svc_inventario]
P1 --- S3[schema clientes<br/>usuario svc_clientes]
P1 --- S4[schema pagos<br/>usuario svc_pagos]
end
subgraph OBJ[Objetivo: instancias y tecnologías por servicio]
O1[(PG pedidos)]
O2[(PG inventario)]
O3[(PG clientes)]
O4[(PG pagos)]
O5[(MongoDB catálogo)]
end
HOY --> PASO1 --> OBJ
El peldaño 1 es barato y ya elimina el problema más grave (el acoplamiento de esquema). Cómo se hace en PostgreSQL:
-- En la instancia actual: un esquema y un usuario por servicio.
CREATE SCHEMA pedidos;
CREATE ROLE svc_pedidos LOGIN PASSWORD '...';
GRANT USAGE, CREATE ON SCHEMA pedidos TO svc_pedidos;
-- Y, sobre todo: el usuario de pedidos NO tiene permisos sobre los otros esquemas.
REVOKE ALL ON SCHEMA public FROM svc_pedidos;
-- Repetir para inventario, clientes y pagos.Con esto, la línea SELECT ... FROM clientes de crearPedido falla en tiempo de ejecución si el servicio de pedidos usa svc_pedidos. Es una forma eficaz de que la regla "no toques tablas ajenas" deje de ser una convención y pase a ser una restricción técnica.
2.1 Por qué el catálogo va a MongoDB y el resto a PostgreSQL
La persistencia políglota no es "usar tecnologías variadas porque se puede"; es elegir por el modelo de datos y el patrón de acceso:
| Criterio | Catálogo | Pedidos, Inventario, Pagos, Clientes |
|---|---|---|
| Forma de los datos | Documentos con atributos variables por categoría (voltaje, talla, compatibilidad): hoy, la incómoda tabla clave-valor atributos_producto. |
Filas regulares con relaciones internas claras (pedido-líneas, stock-reservas). |
| Patrón de acceso | Lectura masiva por ficha y búsqueda con filtros por atributos; escritura poco frecuente. | Escrituras transaccionales con invariantes (cantidad - reservado >= 0, total del pedido, no cobrar dos veces). |
| Necesidad de transacciones | Baja: publicar una ficha es una escritura de un documento. | Alta: la reserva de varias líneas de un pedido debe ser atómica dentro del servicio. |
| Escalado | Horizontal de lectura (réplicas, índices sobre atributos, incluso búsqueda de texto). | Vertical o con réplicas de lectura; volumen manejable (3.000 pedidos/día). |
| Decisión | MongoDB: un documento por producto con atributos como subdocumento; índices por categoría y por atributos frecuentes. |
PostgreSQL: transacciones ACID dentro de cada servicio, restricciones, tipos numéricos exactos para dinero. |
Y una regla de prudencia que Marta impone: como máximo dos tecnologías de base de datos en el sistema. Cada tecnología nueva es copia de seguridad, monitorización, conocimiento y guardias distintas. MongoDB se justifica por el problema 5 de 01-05; una tercera tecnología necesitaría un problema igual de concreto.
- Romper las claves foráneas cruzadas
Recuperemos el esquema de 01-05 y asignemos dueño a cada tabla, marcando las claves foráneas que cruzan fronteras:
| Tabla | Dueño | Clave foránea actual | ¿Cruza frontera? | Qué pasa con ella |
|---|---|---|---|---|
clientes |
Clientes | — | — | — |
productos, atributos_producto |
Catálogo | atributos_producto.producto_id → productos |
No (interna) | Desaparece: pasan a ser un documento. |
stock |
Inventario | stock.producto_id → productos |
Sí | Se elimina la FK; producto_id queda como referencia opaca. |
pedidos |
Pedidos | pedidos.cliente_id → clientes |
Sí | Se elimina la FK; cliente_id queda como referencia opaca. |
lineas_pedido |
Pedidos | lineas_pedido.pedido_id → pedidos |
No (interna) | Se mantiene: es la relación raíz-línea del agregado. |
lineas_pedido |
Pedidos | lineas_pedido.producto_id → productos |
Sí | Se elimina la FK; se añaden columnas copiadas (nombre_producto, precio_unitario). |
pagos |
Pagos | pagos.pedido_id → pedidos |
Sí | Se elimina la FK; pedido_id queda como referencia opaca. |
notificaciones |
Notificaciones | cliente_id (sin FK ya hoy) |
— | Igual. |
Una clave foránea cruzada es incompatible con database-per-service por definición: PostgreSQL no puede comprobar que pedidos.cliente_id existe en una tabla que está en otra base de datos (o en otro servidor). Así que se sustituye por una referencia por identificador: una columna con el id del otro contexto, sin restricción de integridad en la base de datos.
Qué se pierde y cómo se compensa:
| Garantía de la FK | Cómo se recupera sin FK |
|---|---|
| No se puede insertar un pedido de un cliente inexistente. | El servicio valida por contrato al crear: servicio-pedidos consulta GET /clientes/{id} (o comprueba su réplica local) antes de aceptar el pedido. Es lo que hace hoy el paso 1 de crearPedido, pero por API. |
| No se puede borrar un cliente con pedidos. | Se sustituye por política de negocio: los clientes no se borran, se dan de baja (borrado lógico) o se anonimizan; y servicio-clientes publica cliente.eliminado para que otros reaccionen (apartado 5). |
JOIN barato para obtener el nombre del cliente. |
Se pierde de verdad; ver apartado 4. |
| Índice implícito y coherencia de tipos. | Se crean explícitamente: CREATE INDEX sobre cliente_id; los identificadores pasan a ser cadenas opacas (c-1024, p-501, ped-88213), no SERIAL que solo tienen sentido dentro de una base de datos. |
El último punto merece énfasis: en cuanto un identificador viaja entre servicios, deja de poder ser un entero autoincremental local. servicio-pedidos no puede generar pedido_id = 42 y servicio-pagos almacenar 42, porque si un día se restaura una copia o se particiona la tabla, el 42 deja de ser único. TechCorp adopta identificadores de texto generados por el servicio dueño (en el curso, con prefijo legible: ped-, c-, p-, pag-, res-; en producción, UUID o ULID con ese prefijo).
- Las consultas que antes eran un
JOIN
JOINEl caso paradigmático de TechCorp: el panel de administración muestra "pedidos del día con nombre del cliente y nombre de los productos". Hoy:
-- Monolito: un JOIN entre tres áreas, trivial con una sola BD
SELECT p.id, c.nombre AS cliente, pr.nombre AS producto, l.cantidad, p.total
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
JOIN lineas_pedido l ON l.pedido_id = p.id
JOIN productos pr ON pr.id = l.producto_id
WHERE p.creado_en::date = CURRENT_DATE;Con clientes en una base de datos, productos en MongoDB y pedidos en otra, este JOIN no existe. Hay tres alternativas, y elegir bien depende de la frecuencia, la tolerancia a datos ligeramente desactualizados y de quién es el dato:
| Alternativa | Cómo funciona | Ventajas | Inconvenientes | Cuándo usarla |
|---|---|---|---|---|
| Composición de APIs (API composition) | Quien necesita el dato compuesto consulta a cada servicio y une los resultados en memoria. | Sin duplicar datos; siempre actualizado. | Acoplamiento temporal (N llamadas); latencia; problema del "N+1"; imposible paginar/filtrar por campos ajenos. | Consultas poco frecuentes o de pocos elementos (una pantalla de detalle). |
| Réplica de datos vía eventos | El servicio consumidor mantiene una copia local de solo lectura de los campos que necesita, actualizada al recibir eventos del dueño (cliente.actualizado, producto.actualizado). |
Consultas locales rápidas, sin dependencia en tiempo de ejecución; se puede filtrar y paginar. | Consistencia eventual (segundos de retraso); hay que gestionar la suscripción y la carga inicial. | Datos que se leen mucho, cambian poco y toleran retraso: nombre de cliente, nombre de producto. |
| Vista materializada / modelo de lectura (CQRS) | Un componente construye, a partir de los eventos de varios servicios, una tabla o índice pensado exclusivamente para esa consulta. | Consultas complejas y rápidas; no carga a los servicios de origen. | Otra pieza que mantener; consistencia eventual. | Paneles, listados con filtros cruzados, búsquedas. Se desarrolla en 02-05. |
En código, la composición de APIs se parece a esto (esqueleto; el cliente HTTP real, con timeouts y reintentos, se construye en 04-04):
// Composición de APIs para "detalle de un pedido con nombre de cliente"
// Se ejecuta en quien necesita la vista compuesta (por ejemplo, un BFF del panel de administración, 03-04).
async function detallePedidoConCliente(pedidoId) {
const pedido = await pedidosApi.obtener(pedidoId); // GET /pedidos/ped-88213
const cliente = await clientesApi.obtener(pedido.clienteId); // GET /clientes/c-1024
return {
...pedido,
cliente: { nombre: cliente.nombre, email: cliente.email } // solo lo que la vista necesita
};
}
// Para un listado de 200 pedidos, esto son 200 llamadas a Clientes: el "N+1".
// Ahí conviene la réplica o la vista materializada.Y la réplica local, en el esquema de servicio-pedidos:
-- Réplica de solo lectura, dentro de la BD de pedidos, mantenida por eventos de Clientes.
-- Solo los campos que Pedidos necesita para sus listados. Nunca se escribe desde la API de Pedidos.
CREATE TABLE clientes_ref (
cliente_id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
email TEXT NOT NULL,
actualizado_en TIMESTAMPTZ NOT NULL -- fecha del evento que la actualizó
);Decisión de TechCorp para el flujo de pedido:
- Al crear un pedido,
servicio-pedidosconsulta a Catálogo (precio y nombre actuales) y a Clientes (existencia, email, dirección) por composición: son dos llamadas, el dato tiene que ser el actual y el pedido no puede crearse sin ellas. Es el acoplamiento temporal deliberado que señalamos en 02-02. - Para listar pedidos con nombre de cliente,
servicio-pedidosusa su réplicaclientes_ref. - Para el panel de administración con filtros cruzados, una vista materializada alimentada por eventos (02-05).
Fíjate en que las copias congeladas de lineas_pedido (nombre_producto, precio_unitario) no son una réplica: no se actualizan cuando el catálogo cambia. Son datos del pedido. clientes_ref sí es una réplica: si el cliente cambia de nombre, debe reflejarlo.
- Propiedad y ciclo de vida de los datos
Con los datos separados hace falta responder, dato por dato, tres preguntas: quién escribe, quién lee y quién decide cuándo desaparece.
| Dato | Escribe (único) | Lee | Cómo lo leen los demás | Ciclo de vida |
|---|---|---|---|---|
| Ficha de producto | Catálogo | Todos | GET /productos, evento producto.actualizado |
Lo despublica Catálogo; nunca se borra si hay pedidos que lo referencian (los pedidos guardan su copia). |
| Stock y reservas | Inventario | Pedidos (indirectamente) | Eventos stock.reservado / stock.liberado; GET /stock/{productoId} para la web |
Reservas expiran o se consumen; lo decide Inventario. |
| Pedido y líneas | Pedidos | Pagos, Notificaciones, panel | Eventos pedido.*; GET /pedidos/{id} |
Retención legal (años); lo decide Pedidos. |
| Pago | Pagos | Pedidos | Evento pago.confirmado; GET /pagos?pedidoId= |
Retención legal; datos de tarjeta nunca se almacenan (los tiene la pasarela). |
| Cliente | Clientes | Pedidos, Notificaciones | GET /clientes/{id}; eventos cliente.actualizado / cliente.eliminado |
Baja o anonimización a petición (RGPD). |
| Copia congelada nombre/precio en líneas | Pedidos | Pedidos | — | Vive y muere con el pedido. |
Réplica clientes_ref |
Pedidos, solo desde el consumidor de eventos | Pedidos | — | Se actualiza o borra al recibir eventos de Clientes. |
Dos casos que la tabla resuelve y que en el monolito ni se planteaban:
- La réplica tiene dos "escritores" aparentes: la API de Pedidos y el consumidor de eventos de Pedidos. Solo el segundo escribe
clientes_ref; conviene que el usuario de base de datos de la API no tenga permiso de escritura sobre esa tabla, o que el código lo haga imposible por construcción. - El derecho al olvido (RGPD): cuando un cliente pide ser eliminado, hoy basta un
DELETE(que fallaría por las FKs depedidos). Mañana,servicio-clientesanonimiza su fila y publicacliente.eliminado;servicio-pedidosborra la fila declientes_refy anonimiza la dirección de envío de los pedidos antiguos que la ley le obligue a conservar;servicio-notificacionesdeja de tener nada que borrar porque nunca almacenó al cliente. La ausencia de FK obliga a diseñar este flujo explícitamente, lo cual es una ventaja: en el monolito estaba implícito y mal resuelto.
- Migrar los datos sin parar la tienda
Separar el esquema es una cosa; mover los datos que ya existen (miles de pedidos, todo el catálogo) mientras la tienda vende es otra. El procedimiento estándar, aplicado a la extracción del catálogo:
flowchart TB
A[1. Crear el nuevo almacén<br/>MongoDB catálogo, vacío] --> B[2. Carga inicial<br/>script: productos + atributos_producto → documentos]
B --> C[3. Sincronización continua<br/>cambios en el monolito → eventos → MongoDB]
C --> D[4. Lecturas al nuevo almacén<br/>feature flag CATALOGO_REMOTO=true, por porcentaje]
D --> E{¿Datos iguales?<br/>comparación en sombra}
E -- no --> C
E -- sí --> F[5. Escrituras al nuevo almacén<br/>el monolito deja de escribir productos]
F --> G[6. Retirar tabla antigua<br/>tras un periodo de gracia]
Los puntos delicados:
- Paso 3, sincronización. Mientras convivan las dos copias, la antigua sigue recibiendo escrituras (el equipo de catálogo publica fichas a diario). Hay dos formas de propagarlas: que el monolito publique un evento por cada cambio (
producto.actualizado) que el nuevo servicio consume, o captura de cambios (CDC) leyendo el registro de transacciones de PostgreSQL con herramientas como Debezium. TechCorp elige eventos publicados desde el monolito, porque esos mismos eventos harán falta después. - El riesgo del dual write. La tentación es que el monolito escriba directamente en las dos bases de datos ("actualizo la tabla y el documento en la misma función"). Es un error clásico: no hay transacción que abarque PostgreSQL y MongoDB, así que ante cualquier fallo entre las dos escrituras (caída, timeout, excepción) las copias divergen sin que nadie se entere. La escritura debe ser una sola (en la fuente de verdad) y la propagación, asíncrona y reintentable (evento u outbox, que veremos en 02-05).
- Paso 4, comparación en sombra. Antes de fiarse del nuevo almacén, durante un tiempo se ejecutan las lecturas contra ambos y se comparan resultados en un registro. Las discrepancias revelan errores del script de carga o eventos perdidos.
- La feature flag.
CATALOGO_REMOTOes la conmutación del branch by abstraction de 02-02: permite pasar el 1 %, el 10 %, el 100 % de las lecturas al nuevo servicio y volver atrás en segundos. - Paso 6, no tener prisa. La tabla antigua se conserva en solo lectura unas semanas. Borrarla el mismo día del corte es la forma más rápida de descubrir que un informe mensual la usaba.
Un fragmento del script de carga inicial, ilustrativo:
// scripts/migrar-catalogo.js (se ejecuta una vez; ilustrativo)
// Lee productos + atributos_producto del monolito y crea un documento por producto.
async function migrarCatalogo(pg, mongo) {
const { rows: productos } = await pg.query('SELECT id, nombre, precio, categoria, activo FROM productos');
for (const p of productos) {
const { rows: atributos } = await pg.query(
'SELECT clave, valor FROM atributos_producto WHERE producto_id = $1', [p.id]);
await mongo.collection('productos').updateOne(
{ productoId: `p-${p.id}` }, // id opaco con prefijo
{ $set: {
productoId: `p-${p.id}`, nombre: p.nombre, precio: Number(p.precio),
categoria: p.categoria, publicado: p.activo,
atributos: Object.fromEntries(atributos.map(a => [a.clave, a.valor])), // clave-valor -> subdocumento
migradoEn: new Date()
} },
{ upsert: true }); // idempotente: se puede relanzar
}
}Observa upsert: true: el script se puede ejecutar varias veces sin duplicar nada. Es la primera aparición práctica de la idempotencia que la lección siguiente convierte en regla.
- El esquema resultante de cada servicio de TechCorp
Con todo lo anterior, así quedan los almacenes. Solo mostramos completos los dos que más protagonismo tendrán en la saga; el resto, resumido.
7.1 servicio-pedidos (PostgreSQL, base de datos pedidos)
-- Base de datos: pedidos. Usuario: svc_pedidos. Nadie más se conecta.
CREATE TABLE pedidos (
pedido_id TEXT PRIMARY KEY, -- 'ped-88213', generado por este servicio
cliente_id TEXT NOT NULL, -- referencia opaca a Clientes: SIN clave foránea
estado TEXT NOT NULL CHECK (estado IN ('PENDIENTE','STOCK_RESERVADO','PAGADO','CONFIRMADO','CANCELADO')),
total NUMERIC(10,2) NOT NULL,
direccion_envio JSONB NOT NULL, -- copia congelada de la dirección en el momento del pedido
motivo_cancelacion TEXT, -- 'SIN_STOCK', 'PAGO_RECHAZADO', ... (se rellena en 02-05)
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
actualizado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_pedidos_cliente ON pedidos (cliente_id); -- explícito: ya no lo da la FK
CREATE TABLE lineas_pedido (
pedido_id TEXT NOT NULL REFERENCES pedidos(pedido_id), -- FK INTERNA: raíz -> línea del agregado
linea INT NOT NULL,
producto_id TEXT NOT NULL, -- referencia opaca a Catálogo: SIN clave foránea
nombre_producto TEXT NOT NULL, -- copia congelada
precio_unitario NUMERIC(10,2) NOT NULL, -- copia congelada
cantidad INT NOT NULL CHECK (cantidad > 0),
PRIMARY KEY (pedido_id, linea)
);
-- Réplica de solo lectura de Clientes (apartado 4), alimentada por eventos
CREATE TABLE clientes_ref (
cliente_id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
email TEXT NOT NULL,
actualizado_en TIMESTAMPTZ NOT NULL
);Compáralo con el pedidos / lineas_pedido de 01-05: han desaparecido las FKs a clientes y productos, han aparecido las copias congeladas y la dirección, los identificadores son texto, y hay dos estados nuevos (STOCK_RESERVADO, PAGADO) que la saga de 02-05 explicará. En 02-05 se añadirá una tabla más a esta base de datos: outbox.
7.2 servicio-inventario (PostgreSQL, base de datos inventario)
-- Base de datos: inventario. Usuario: svc_inventario.
CREATE TABLE stock (
producto_id TEXT PRIMARY KEY, -- referencia opaca a Catálogo: SIN clave foránea
cantidad INT NOT NULL CHECK (cantidad >= 0),
reservado INT NOT NULL DEFAULT 0 CHECK (reservado >= 0),
ubicacion TEXT,
umbral_reposicion INT NOT NULL DEFAULT 0,
CHECK (reservado <= cantidad) -- el invariante del contexto, protegido por la BD
);
CREATE TABLE reservas (
reserva_id TEXT PRIMARY KEY, -- 'res-40021'
pedido_id TEXT NOT NULL UNIQUE, -- una reserva por pedido: base de la idempotencia (02-05)
estado TEXT NOT NULL CHECK (estado IN ('ACTIVA','CONSUMIDA','LIBERADA')),
creada_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expira_en TIMESTAMPTZ -- reservas no confirmadas caducan
);
CREATE TABLE lineas_reserva (
reserva_id TEXT NOT NULL REFERENCES reservas(reserva_id), -- FK interna
producto_id TEXT NOT NULL,
cantidad INT NOT NULL CHECK (cantidad > 0),
PRIMARY KEY (reserva_id, producto_id)
);Lo importante: el invariante reservado <= cantidad vive aquí y solo aquí, como restricción de base de datos. En el monolito, lo protegía la cláusula WHERE cantidad - reservado >= $1 escrita en crearPedido, es decir, en el código de otro contexto.
7.3 servicio-catalogo (MongoDB, base de datos catalogo, colección productos)
{
"_id": "p-501",
"productoId": "p-501",
"nombre": "Auriculares BT X200",
"descripcion": "Auriculares inalámbricos con cancelación de ruido...",
"categoria": "audio",
"precio": 59.90,
"publicado": true,
"atributos": { "color": "negro", "autonomiaHoras": 30, "bluetooth": "5.3", "cancelacionRuido": true },
"imagenes": ["x200-frontal.webp", "x200-lateral.webp"],
"actualizadoEn": "2026-08-14T09:12:00Z"
}La tabla clave-valor atributos_producto (problema 5 de 01-05) desaparece: cada producto lleva los atributos que le corresponden, y un índice sobre categoria más índices selectivos sobre atributos frecuentes (atributos.color) resuelven la búsqueda con filtros. No hay ninguna referencia a stock ni a pedidos.
7.4 El resto, en una línea cada uno
servicio-pagos(PostgreSQLpagos): tablapagos (pago_id, pedido_id sin FK, importe, estado AUTORIZADO/CAPTURADO/RECHAZADO/REEMBOLSADO, referencia_pasarela, creado_en), conUNIQUE (pedido_id)para no cobrar dos veces.servicio-clientes(PostgreSQLclientes):clientes (cliente_id, keycloak_sub, email UNIQUE, nombre, activo)ydirecciones (direccion_id, cliente_id FK interna, calle, cp, ciudad, predeterminada).servicio-notificaciones: registro mínimoenvios (envio_id, pedido_id, tipo, destinatario, enviado_en, estado)para no reenviar; puede ser una tabla pequeña o incluso el propio registro de la cola.
- Implicaciones para reporting y analítica
Queda una pregunta que Marta hará en cuanto vea el diseño: "¿y el informe de ventas por categoría y ciudad?". Hoy es un JOIN de cuatro tablas. Mañana los datos están en cinco bases de datos, una de ellas MongoDB.
La respuesta de la arquitectura es que la analítica no se hace contra las bases de datos operativas de los servicios, ni antes ni después de los microservicios (hacerlo era, de hecho, una de las causas de los bloqueos del problema 3). Se construye una base de datos de lectura separada (almacén analítico o data warehouse) alimentada por los eventos que ya publican los servicios (pedido.confirmado con líneas y dirección, producto.actualizado con categoría) o por exportaciones periódicas. Es la misma idea de la vista materializada del apartado 4, llevada a escala de empresa: consistencia eventual (minutos u horas de retraso son aceptables para un informe) a cambio de no tocar los servicios en producción. Se desarrolla la mecánica en 02-05 (CQRS) y no volveremos sobre ello salvo de pasada.
Errores Comunes y Consejos
- Separar los servicios y dejar la base de datos compartida "de momento". Ese "momento" dura años. Como mínimo, esquemas y usuarios separados desde el primer servicio extraído.
- Mantener las FKs cruzadas "porque son gratis". No lo son: son la razón por la que el catálogo no puede mudarse a MongoDB ni un servicio migrar su esquema sin coordinarse.
- Replicar todo el modelo del otro servicio. La réplica lleva los campos que necesitas, no la tabla entera.
clientes_reftiene tres columnas, no doce. - Confundir copia congelada con réplica. El precio de una línea de pedido no debe actualizarse; el nombre del cliente en
clientes_refsí. Documenta cuál es cuál. - Hacer dual write durante la migración. Una escritura, en la fuente de verdad; la copia se propaga por eventos y se verifica en sombra.
- Usar
SERIALcomo identificador que viaja entre servicios. Identificadores opacos, generados por el dueño, únicos globalmente. - Consejo: para cada consulta del monolito que hoy hace
JOINentre áreas, apunta en una tabla: frecuencia, tolerancia a retraso, campos ajenos que necesita, y decide composición / réplica / vista materializada. Esa tabla es tu plan de datos.
Ejercicios
Ejercicio 1: Clasificar consultas
Para cada consulta del monolito, indica la alternativa más adecuada (composición de APIs, réplica por eventos o vista materializada) y justifica en una línea: (1) la página de detalle de un pedido para el cliente, que muestra el nombre del cliente y las líneas; (2) el listado del panel de administración "pedidos de hoy" con nombre de cliente, paginado y filtrable por ciudad; (3) el correo de confirmación, que necesita el email del cliente y los nombres de los productos; (4) el informe mensual de ventas por categoría de producto.
Ejercicio 2: La FK que falta
pagos.pedido_id pierde su clave foránea a pedidos. Explica qué garantías se pierden y cómo las recupera servicio-pagos por diseño (piensa en: cómo sabe que el pedido existe, qué impide dos pagos para el mismo pedido, y qué pasa si llega un stock.reservado de un pedido que Pagos nunca ha visto).
Ejercicio 3: Un esquema para Clientes
Escribe el SQL de la base de datos clientes de servicio-clientes (tablas clientes y direcciones) siguiendo las convenciones de esta lección (identificadores opacos, sin FKs cruzadas, restricciones internas explícitas), e indica qué evento debería publicar el servicio cuando un cliente cambia su email y qué servicios lo consumirían.
Soluciones
Ejercicio 1
- Composición (o incluso nada: el pedido ya guarda las líneas con nombre y precio congelados; para el nombre del cliente, una llamada a
GET /clientes/{id}o la réplicaclientes_ref). Es una consulta de un solo elemento y debe mostrar el estado actual. - Réplica (
clientes_ref) para el nombre, y probablemente vista materializada si se filtra por ciudad de la dirección de envío y se combina con datos de otros contextos: paginación y filtros cruzados no funcionan con composición (N+1). - Ninguna de las tres en Notificaciones: los datos viajan en el evento
pedido.confirmado(email, nombre, líneas con nombre de producto). Notificaciones no consulta ni replica; es conformist (02-03). - Vista materializada / almacén analítico alimentado por eventos: consulta agregada, tolerante a retraso, que no debe cargar a los servicios en producción (apartado 8).
Ejercicio 2
Se pierde: (a) la garantía de que el pedido existe; (b) el bloqueo de borrado; (c) el JOIN. Cómo lo recupera Pagos: (a) Pagos no crea pagos por iniciativa propia: reacciona a stock.reservado, que lleva pedidoId e importe, y ese evento solo existe si Pedidos creó el pedido; opcionalmente valida con GET /pedidos/{id} en caso de duda. (b) La restricción UNIQUE (pedido_id) en su propia tabla pagos impide dos cobros para el mismo pedido aunque el evento llegue duplicado (idempotencia, 02-05). (c) Si llega un stock.reservado de un pedido desconocido para Pagos, es la situación normal: Pagos no necesita "haber visto" el pedido antes; el evento es la única entrada. Lo que sí debe hacer es rechazar eventos malformados o de importe inválido y registrar el caso. Y si un pedido se anula, no se borra: Pagos recibirá pedido.cancelado y reembolsará si había cobrado.
Ejercicio 3
CREATE TABLE clientes (
cliente_id TEXT PRIMARY KEY, -- 'c-1024', generado por este servicio
keycloak_sub TEXT NOT NULL UNIQUE, -- referencia opaca al usuario en Keycloak (externo)
email TEXT NOT NULL UNIQUE, -- invariante del contexto: email único
nombre TEXT NOT NULL,
activo BOOLEAN NOT NULL DEFAULT TRUE, -- baja lógica en lugar de DELETE
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE direcciones (
direccion_id TEXT PRIMARY KEY,
cliente_id TEXT NOT NULL REFERENCES clientes(cliente_id), -- FK interna al agregado Cliente
calle TEXT NOT NULL, cp TEXT NOT NULL, ciudad TEXT NOT NULL,
predeterminada BOOLEAN NOT NULL DEFAULT FALSE
);Evento: cliente.actualizado con { clienteId, email, nombre, actualizadoEn }. Lo consumiría servicio-pedidos para actualizar clientes_ref (réplica). servicio-notificaciones no lo necesita (recibe el email en cada evento de pedido) y servicio-pagos tampoco (no conoce clientes). Un cliente.eliminado adicional dispararía la anonimización descrita en el apartado 5.
Conclusión
Hemos dado el paso físico que separa de verdad los servicios: una base de datos por servicio. Hemos visto por qué la base de datos compartida concentra todos los acoplamientos, la escalera de aislamiento (esquemas y usuarios separados como paso intermedio, instancias separadas como objetivo, MongoDB para el catálogo y PostgreSQL para el resto por su modelo de datos y su patrón de acceso, con el límite de dos tecnologías), cómo se rompen las claves foráneas cruzadas (pedidos.cliente_id, lineas_pedido.producto_id, stock.producto_id, pagos.pedido_id pasan a referencias opacas de texto, con validación por contrato e identificadores generados por el dueño), qué hacer con los JOIN perdidos (composición de APIs para el detalle, réplica clientes_ref alimentada por eventos para los listados, vista materializada para el panel), quién escribe cada dato y cómo se gestiona su ciclo de vida (incluido el borrado RGPD), cómo migrar sin dual write (carga inicial idempotente, sincronización por eventos, lectura en sombra, feature flag) y el esquema resultante de servicio-pedidos, servicio-inventario y servicio-catalogo.
Ese esquema deja una pregunta abierta a propósito: pedidos.estado admite STOCK_RESERVADO y PAGADO, y reservas tiene expira_en. Son las huellas de lo que ya no existe: la transacción única de crearPedido. La siguiente lección la sustituye por una saga, con sus compensaciones, su máquina de estados, el patrón outbox que garantiza que los eventos salen si y solo si el cambio se guardó, la idempotencia de los consumidores, y las vistas de lectura de CQRS; y decidirá si TechCorp necesita event sourcing (adelanto: por ahora, no).
Curso de Microservicios
Módulo 1: Introducción a los Microservicios
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
