Cumpliendo la Ley con F# y Event Sourcing (parte 2): cuando los eventos no saben qué día es hoy
por Pedro León
En un artículo anterior contamos cómo modelamos con F# y Event Sourcing el ciclo de vida de un borrador de factura, y cómo el sistema de tipos nos ayudó a convertir requisitos legales en código que simplemente no compila si el requisito no se cumple. Este artículo es la continuación natural de aquel: un caso concreto, pequeño en apariencia, que nos obligó a repensar una parte del diseño para no repetir el mismo error dos veces.
Strings Digital Products
Somos un estudio de desarrollo de software con más de 20 años de experiencia profesional con base en Madrid, especializado en sistemas críticos, arquitecturas orientadas a eventos y diseño de dominio.
Trabajamos con clientes de cualquier parte del mundo, construyendo productos digitales robustos, mantenibles y pensados para durar.
Puedes leer la versión en inglés de este artículo aquí
El requisito, esta vez, era sencillo de enunciar: la fecha de operación de una factura no puede ser posterior a su fecha de emisión. Implementarlo bien, sin embargo, no lo fue tanto.
El origen legal del requisito
Este requisito no es una interpretación libre nuestra: viene directamente del Reglamento de Facturación (Real Decreto 1619/2012). Su artículo 9 ("Plazo para la expedición de las facturas") determina que las facturas deberán ser expedidas en el momento de realizarse la operación (cuando el destinatario es un particular) o, a más tardar, antes del día 16 del mes siguiente a aquel en que se haya producido el devengo (cuando el destinatario es un empresario o profesional).
Dicho de otro modo: la factura no puede documentar una operación que, desde el punto de vista temporal, todavía no ha ocurrido. La fecha de operación nunca puede ser posterior a la fecha de expedición.
Que este no es un matiz menor lo confirma también el propio sistema Verifactu, que lo valida explícitamente como error de sistema: el Error 1112 ("Fecha de expedición posterior a la actual") salta precisamente cuando una factura se intenta expedir con una fecha inconsistente con el momento real de expedición. Es, en la práctica, la misma comprobación que nosotros queríamos garantizar en nuestro propio sistema — solo que quisimos hacerlo antes, en el borrador, y no solo en el momento final de la expedición.
El problema: el reloj no vive donde nosotros creíamos
Nuestro agregado Draft (borrador) se folda a partir de eventos: Placed, IdentityVerified, BeginIssuance, IssuanceCommited... Equinox cachea el resultado de ese fold, porque asumir que es puro y determinista es justamente lo que le permite no releer el stream entero cada vez.
El problema apareció en cuanto quisimos preguntar "¿la fecha de operación ya no está en el futuro?". Esa pregunta depende del reloj, no de los eventos. Y el reloj cambia sin que ningún evento se dispare. Si esa respuesta se colara en el estado persistido —aunque fuera como un campo calculado una sola vez—, tendríamos un borrador que podría quedarse "congelado" mostrando una respuesta obsoleta hasta que un evento no relacionado volviera a tocar el stream. Es exactamente el tipo de bug silencioso que este enfoque debería evitarnos, no provocarnos.
Descartamos dos soluciones rápidas por la misma razón, aunque parecieran distintas:
- Calcular la fecha en el endpoint de lectura y, por separado, repetir el cálculo en el decisor de escritura. Dos implementaciones del mismo criterio son, tarde o temprano, dos criterios distintos.
- Guardar el resultado de esa comparación como parte del estado persistido. Esto es justo lo que Equinox cachea, así que habríamos metido el reloj dentro de lo que debe ser puro.
¿Cómo evitamos que lectura y escritura puedan discrepar?
La respuesta que nos convenció fue dejar de tratarlo como un problema de disciplina ("acordaos de llamar siempre a la misma función") y convertirlo en un problema de tipos.
Separamos el estado en dos tipos distintos:
type UncheckedDraftStatus =
| AwaitingIssuance of AwaitingIssuance
| IssuanceInProgress of IssuanceInProgress
| Issued of Issued
// No existe un caso "Issuable" aquí. No se puede construir.
type CheckedDraftStatus =
| NotIssuable of NotIssuable
| Issuable of Issuable
| IssuanceInProgress of IssuanceInProgress
| Issued of Issued
UncheckedDraftStatus es lo único que produce Fold.evolve, y es deliberadamente ciego al reloj: no tiene ningún hueco donde guardar "es o no emitible ahora mismo", porque esa pregunta no le corresponde a él responderla. La única forma de obtener un CheckedDraftStatus.Issuable es pasar por una función:
let checkIssuabilityTemporalConditions
(currentDate: System.DateOnly)
: UncheckedDraftStatus -> CheckedDraftStatus =
...
Y esa función la llaman tanto Decide.tryBeginIssuance (la escritura) como Queries.findWithEtag (la lectura), siempre con una fecha capturada en el momento, nunca dentro del propio Fold.evolve. No es que lectura y escritura "deban" estar de acuerdo por convención: es que, al ser literalmente la misma función, no hay forma de que dejen de estarlo. El compilador se convierte en el garante de esta invariante, no la disciplina del equipo ni una batería de tests.
El siguiente diagrama resume esta máquina de estados: a la izquierda, el estado persistido y cacheado por Equinox, guiado solo por eventos; a la derecha, el resultado transitorio y nunca cacheado que solo existe cuando alguien pregunta con una fecha concreta en la mano.
Un motivo no debería tapar al otro
Al hacer este cambio nos encontramos con un segundo problema, más sutil. Antes de esta versión, un borrador "no emitible" solo podía tener un motivo: partes sin verificar, o fecha en el futuro, nunca los dos. Pero en la práctica un borrador puede tener ambos problemas a la vez, y reportar solo uno escondía información relevante para quien integra con nuestra API.
La solución fue, otra vez, de tipos: en vez de un DU con un cuarto caso mutuamente excluyente, NotIssuable pasó a llevar ambos hechos como campos independientes, siempre presentes:
type NotIssuable =
{ InvoiceKind: InvoiceKind
PartiesVerification: PartiesVerification
OperationDateVerification: OperationDateVerification }
PartiesVerification y OperationDateVerification son dos hechos ortogonales. Cuando ambos fallan, ambos se reportan a la vez, en el nuevo campo notIssuableReasons que ahora exponemos en la API. No fue necesario ningún if extra para lograrlo: simplemente dejó de haber un sitio donde uno pudiera esconder al otro.
Lo que nos llevamos de este caso
Esta funcionalidad, vista desde fuera, es pequeña: un campo nuevo, no rompe compatibilidad, tres líneas de descripción en el changelog. Pero el camino para llegar hasta ahí reafirma algo que ya intuíamos del artículo anterior: en un sistema con Event Sourcing, la pregunta "¿qué puede cachear el fold?" es tan importante como "¿qué dice la ley?". Hacer que los estados incorrectos sean irrepresentables —la idea de Scott Wlaschin que ya citamos la vez pasada— no se aplica solo a los datos del dominio, también se aplica al propio ciclo de vida del agregado y a su relación con el tiempo.
Cuando el requisito tiene implicaciones legales, no nos basta con que el código "normalmente" haga lo correcto. Preferimos que sea el compilador quien no nos deje hacer lo incorrecto. Una vez más, sin un lenguaje como F# y sin la disciplina que trae Event Sourcing bien aplicado, esta garantía habría sido mucho más difícil de sostener en el tiempo.