Tu CQRS en banca tiene dos verdades y nadie las reconcilia
El comando pasó, el evento llegó a SQS y la pantalla mostró un saldo falso. El bug no estaba en el command side, sino en el lado que nadie mira.
Un cliente llamó el miércoles a las 9:07 AM: la app le mostraba un saldo que no existía. Había aprobado un crédito de dos millones el martes. En la base transaccional el dato estaba perfecto — comando ejecutado, outbox publicado, evento en SQS.
Y aun así la pantalla mintió. No con un error: con un 200 OK y un número falso.
Ese día entendí algo que nadie me enseñó: el lado fácil de CQRS es el command side. El difícil —el que nadie programa con cuidado— es el query side. Y en banca, el que miente es el que el cliente mira.
El lado que todos programan primero
Cuando aterrizas CQRS en un core de originación de crédito, arrancas por los comandos: aggregate, invariantes, @CommandHandler, outbox. Es la parte que se parece a lo que ya sabías y se siente como progreso.
El lado de las queries se dibuja el viernes como "una vista desnormalizada" y se implementa con apuro, cuando ya hay que publicar. Y ahí empieza el misterio: un read model no es una vista, es otro sistema con su propia verdad.
- Es una base de datos que puede desincronizarse sin que nadie lo note.
- Es un consumidor más: recibe, deduplica, muta y a veces falla a mitad de camino.
- Es un contrato con el cliente, firmado sin comité y sin fecha de expiración.
- Y es, y más, el lugar donde nadie mira porque "es solo lectura".
El día que el replay duplicó saldos
La causa raíz —dos días de hunt— no era el lag de la proyección: era que el mismo evento se procesaba dos veces. Una por el EventBus local de NestJS, otra por el consumer de SQS. Los dos handlers mutando el mismo read model.
// La proyección "obvia". Y por eso duplica saldos en cada replay.
@EventsHandler(LegadoAcreditadoEvent)
export class SaldoReadModel {
async handle(event: LegadoAcreditadoEvent) {
await this.db.query(
`UPDATE read_model_saldo SET saldo = saldo + $1 WHERE cuenta_id = $2`,
[event.monto, event.cuentaId],
);
}
}
Funciona mientras cada evento llegue una sola vez. Los brokers lo prometen. Mienten cuando hay red, rebalanceo y un consumidor que diesela y vuelve a leer.
// Idempotencia real: dedupe por eventId ANTES de mutar.
@EventsHandler(LegadoAcreditadoEvent)
export class SaldoReadModel {
async handle(event: LegadoAcreditadoEvent) {
const seen = await this.db.query(
`INSERT INTO read_model_eventos (event_id) VALUES ($1)
ON CONFLICT DO NOTHING RETURNING event_id`,
[event.eventId],
);
if (seen.rowCount === 0) return; // ya lo procesamos
await this.db.query(
`UPDATE read_model_saldo SET saldo = saldo + $1 WHERE cuenta_id = $2`,
[event.monto, event.cuentaId],
);
// y aquí falta algo: una transacción que cubra el dedupe y el update
}
}
¿Ves el detalle? Si el dedupe y el update no viven en la misma transacción, cambias un bug por otro. Eso no lo menciona ningún post de CQRS de internet.
Read-your-writes: la pregunta que hay que hacerle al negocio
Con la proyección arreglada apareció la segunda mitad: qué tan viejo puede ser el dato que ve el cliente. El motor de riesgo puede leer con cinco segundos de atraso. El estado de una solicitud de crédito, no. Nadie te dice cuál es cuál: hay que sentarlo con negocio y pagear.
// Read-your-writes: el cliente acaba de enviar un comando.
const commandVersion = await this.versions.lastVersion(cuentaId);
const viewVersion = await this.readModel.versionOf(cuentaId);
if (viewVersion < commandVersion) {
throw new ReadModelStaleError(cuentaId, viewVersion, commandVersion);
}
Eso detecta el desfase, no lo resuelve. La parte que sí lo resuelve —esperar a que la proyección alcance la versión, con timeout y degradación a lectura directa— es larga, y es el post que todavía no escribí.
Lo que este post no te va a enseñar
Podría cerrar con un checklist. Prefiero no hacerlo. Hay tres cosas que merecen su propio artículo y quedan abiertas:
- Replay controlado: reconstruir un read model sin duplicar saldos ni perder eventos.
- Versionado de read models: cambiar la forma de la vista sin romper a quien ya la lee.
- Detección de drift entre la base transaccional y el read model.
- Y más, que es la parte que más me costó aprender.
¿Por qué no las cuento hoy? Porque las tres parten de una decisión que todavía no tomamos: si el log de eventos es la fuente de verdad, o si lo es la base transaccional. Esa decisión cambia todo lo demás. Y sigue abierta.
¿Tu read model es una caché con mala prensa, o es un segundo sistema con su propia verdad? Si no puedes responder eso, no tienes CQRS: tienes dos bases de datos y una promesa rota.
Mientras tanto, el cliente sigue llamando. Y el saldo sigue en cero en la pantalla.
Compartir artículo
Escrito por
Oscar Vargas
Apasionado por el desarrollo web, las herramientas modernas y compartir lo que aprende en el camino. Más artículos abajo.
Siguiente artículo →
Go te devuelve el error, pero el dinero ya se movió
Artículos relacionados
El Provider de NestJS que Firmó un Contrato que tu Banco No Renovó
Tu @Injectable vive en el scope equivocado y el proveedor externo ya cambió las reglas. Lo que aprendí cuando PSE, Bre-B y Transbank dejaron de hablar con mi grafo.
Go te devuelve el error, pero el dinero ya se movió
Un error nil, una transferencia duplicada y 48 horas de conciliación rota. En Go el fallo no se lanza: se esconde. Te cuento dónde lo perdimos.
NestJS Dependency Injection en Banking: Lo que Nadie te Cuenta
Detrás de cada originación de crédito hay un grafo de dependencias que puede tumbar producción. Lo que aprendí moviendo CDT, Bre-B y consumer credit a NestJS.