Cumpliendo la Ley con F# y Event Sourcing

12/23/2025

por Pedro León

Este artículo forma parte del FsAdvent 2025, gracias a Sergey Tihon por esta bonita iniciativa para la comunidad.

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 origen

En 2021 se aprobó en España una ley que afecta directamente a los desarrolladores de software de facturación.
Por primera vez en la historia del país, el Gobierno regulaba cómo debía comportarse un programa a nivel técnico,
exigiendo garantías en la emisión de facturas mediante una ley y su correspondiente Reglamento.

El incumplimiento del reglamento técnico conlleva multas entre 50.000 € y 150.000 € para los fabricantes de software.
Por primera vez, un error de diseño o una mala implementación podía traducirse en una sanción de cientos de miles de euros.

Eligiendo la tecnología para cumplir con la Ley

Ante este escenario decidimos ser muy conservadores.
Mejor ir despacio que estrellarnos.

Tomamos una decisión clave desde el inicio:

Representaríamos los requisitos legales de tal manera que pudiésemos compilarlos.

El compilador de F# sería nuestro aliado.
Si algo no compilaba, no podía llegar a producción.
Si no llegaba a producción, no podía generar una factura ilegal.

Para conseguirlo, utilizamos dos herramientas bien conocidas por esta comunidad:

  • Modelamos cada flujo de trabajo mediante tipos, garantizando que no fuese posible conectar dos pasos de forma incorrecta.
    Pusimos especial énfasis en hacer que los estados erróneos fuesen irrepresentables, siguiendo el principio de
    “making illegal states unrepresentable” (Scott Wlaschin).

  • Adoptamos Event Sourcing como patrón arquitectónico.
    Nuestro caso de uso encaja especialmente bien con este enfoque: solo por usar Event Sourcing cumplimos automáticamente tres requisitos legales clave: integridad, trazabilidad e inalterabilidad de los documentos de negocio.

    Para ello utilizamos Equinox y Propulsion sobre MessageDB.

Modelando el flujo de trabajo de la emisión de facturas

En nuestro servicio existen decenas de flujos de trabajo modelados.
Para este artículo nos centraremos en uno especialmente relevante desde el punto de vista legal:
el flujo de trabajo de emisión de facturas.

Emitir una factura consiste en generar una factura a partir de un borrador de factura y una serie de facturación.

El borrador es el documento de negocio sobre el que el usuario trabaja: modifica ítems, precios, datos del receptor, etc.
Cuando considera que el borrador es correcto, puede emitir una factura, que a partir de ese momento debe ser inmutable.

La factura necesita un número de factura con implicaciones legales muy estrictas: debe ser consecutivo, coherente con la fecha y único.
No pueden existir huecos ni duplicados, ni dos facturas derivadas del mismo borrador.

Para emitir una factura intervienen tres agregados distintos:

  • Draft: el documento de negocio previo, mutable.
  • Serie: la responsable de asignar números de factura consecutivos.
  • Invoice: la factura legal e inmutable, identificada por un InvoiceId.

Además, los tres agregados deben cambiar su estado interno de forma coherente:

  • El Draft no debe permitir emitir más de una factura desde el mismo borrador.
  • La Serie debe emitir exactamente el número que corresponde.
  • La Invoice no puede crearse dos veces con el mismo InvoiceId.

Equinox nos garantiza transaccionalidad a nivel de agregado, pero no entre varios agregados a la vez. Y, sin embargo, el proceso debía ser atómico desde el punto de vista del dominio.

Debido a que no podíamos hacer una transacción a nivel de base de datos de manera natural, decidimos que lo mejor era modelar la transacción a nivel de dominio.

¿Cómo hicimos transaccional un proceso sin transacciones en la base de datos?

Tenemos tres agregados en nuestro proceso de Issuance y a los tres les serán añadidos eventos si el proceso se lleva a cabo de manera correcta. Del mismo modo que en una transacción de base de datos tiene un BEGIN y será finalizada con un COMMIT o un ROLLBACK, nosotros haremos lo mismo a nivel de agregado.

El agregado natural para hacer este proceso es el propio Draft. Debemos asegurarnos que al realizar el proceso de emisión de factura desde el borrador éste no se pueda iniciar más de una vez.

Si viésemos esto en pseudocódigo de este workflow de alto nivel, tendríamos algo así (la implementación real es más compleja):

async {

    // BEGIN
    // Sólo el primer proceso que invoca esta línea consigue pasar.
    let! draftInIssuanceProgress : DraftReadModel.IssuanceInProgress = 
        draftId
        |> DraftService.tryStartIssuance 

    try     

        // Si se ha podido iniciar la transacción, los siguientes comandos siempre tendrán éxito por la naturaleza de su diseño excepto si ocurre una excepción.
        let! invoiceId =
            SerieService.issueNextNumber  (draftId, draftInIssuanceProgress)

        let! issuedInvoice = 
            InvoiceService.issue invoiceId draftInIssuanceProgress
        
        do! IssuanceInProgress = 
            DraftService.commitIssuance draftId invoiceId

        return invoiceId, issuedInvoice
    with 
    | ex -> 
        Logger.fatal "...." ex 
        DraftService.rollbackIssuance draftId ex

        ....
}

Nuestro pseudocódigo muestra una transacción simulada a nivel de aplicación que en principio funciona bien, pero hay un detalle importante que cualquier programador experimentado ya habrá advertido. No se trata de una verdadera transacción y tiene una sutil trampa esperando a aparecer.

La trampa, nada evidente, esperando aparecer en el momento más inesperado es un error al tratar de hacer rollbackIssuance que dejará el agregado Draft en un estado de transacción iniciada para siempre.

Nosotros lo solucionamos con un proceso externo con el patrón Automation y ajustando cuidadosamente los parámetros timeout de la conexión con nuestra base de datos:

  • Por un lado en nuestro connectionString de Postgresql tenemos configurados los parámetros de timeout como Timeout=1; Command Timeout=1; Cancellation Timeout=1 que indica que se espera 1 segundo en caso de establecer conexión o ejecutar un comando o leer una respuesta cuando se cancela o hay un timeout de una query.

  • Por otro tenemos un proceso externo que sigue el Automation Pattern y que tiene su propio pool de conexiones. Si el proceso detecta que un agregado lleva más de 10.0 segundos con la transacción iniciada, hace un rollback sobre el agregado.

La combinación de estos tiempos nos da seguridad pues con seguridad pasados 10.0 segundos sin cambiar el estado significa que cualquier ejecución del workflow ha lanzado una excepción por timeout con la base de datos.

¿Cómo modelamos el agregado Draft para que se comportase transaccionalmente?

Tras ver el pseudocódigo del proceso, pasemos al modo en el que lo hemos implementado en un agreado. Para ello modelamos 3 eventos nuevos que no existían en el agregado hasta ese momento:

  • IssuanceStarted es el evento que marca el inicio de la transacción en el dominio. Lo podríamos entender como BEGIN en el mundo de las transacciones en las bases de datos.
    Este evento sólo se puede generar desde el comando StartIssuance. Una vez que este evento es añadido al stream del agregado, el estado cambia desde Open a IssuanceInProgress. Este comando tendrá tres posibles resultados al invocar la función decide del agregado (ver Decider) :

    • Ok [IssuanceStarted], que sólo ocurre cuando el agregado está en el estado Open.

    • Error IssuanceIsAlreadyInProgress que ocurre cuando el borrador está en estado IssuanceInProgress e indica que la transacción ya se ha iniciado en este borrador y por lo tanto no se debe iniciar otra.

    • Error IssuanceIsAlreadyCommitted que ocurre cuando el borrador está en el estado IssuanceCommitted e indica que este borrador ya ha generado una factura y por lo tanto no puede volver a ser emitido.

  • IssuanceCommitted es el evento del agregado Draft que refleja que el proceso de emisión de factura ha culminado con éxito. Una vez que se añade este evento al stream de agregado el estado interno de éste pasa a IssuanceCommitted y a partir de ese momento el agregado ya no acepta ningún otro evento. Sólo se puede llegar a este evento a través del comando CommitIssuance. Este comando tendrá tres posibles resultados al invocar la función decide del agregado:

    • Ok [IssuanceCommitted {InvoiceId: InvoiceId}], que indica el proceso ha culminado con éxito y además guarda el identificado de factura que ha emitido (lo que nos facilita generar funcionalidades de enlazado de agregados en proyecciones futuras en nuestros aplicativos).

    • Error IssuanceNotStarted, que indica que el agregado no ha iniciado una transacción para la emisión de la factura.

    • Error IssuanceAlreadyCommitted, que indica que el agregado ya generó una factura anteriormente.

  • Por último, el evento IssuanceRolledBack es el evento del agregado Draft que refleja que el proceso de emisión ha fracasado por algún motivo (por un bug, un error de conexión con las BBDD de los otros agregados o cualquier otro motivo). Una vez se añade este evento al stream, el agregado vuelve al estado Open (estado que permite reiniciar la transacción de nuevo). Sólo se puede llegar a este evento a través del comando RollbackIssuance. Este comando tendrá tres posibles resultados al invocar la función decide del agregado:

    • Ok [IssuanceRolledBack {Error: IssuanceError}], que indica el proceso ha culminado ha fallado y además guarda el error con cierta información relevante para monitorización y auditoría.

    • Error IssuanceNotStarted, que indica que el agregado no ha iniciado una transacción para la emisión de la factura y por lo tanto no se puede hacer rollback.

    • Error IssuanceAlreadyCommitted, que indica que el agregado ya había generado una factura anteriormente y por lo tanto no admite rollback

Todo lo anterior lo podemos ver en este diagrama en el que las transiciones entre estados son los evento etiquetados en las flechas y el estado interno del agregado Draft se encuentra en cada caja.

Diagrama draft-maquina de estados

Modelando el workflow con tipos

Teniendo en cuenta todo lo anterior, pasamos a modelar el workflow. Diseñando con tipos en las signaturas de las dependencias nos ponemos guardarraíles a nosotros mismos evitando que cometamos errores no intencionados haciendo que los tipos nos obliguen a seguir el orden correcto.

Nuestro workflow tiene esta signatura:

type TryIssueWorkflow =
    CurrentDate
    -> TryBeginIssuanceWorkflow
    -> AssignNumberWorkflow
    -> IssueInvoiceWorkflow
    -> TryCommitIssuanceWorkflow
    -> TryRollbackIssuanceWorkflow
    -> DraftId
    -> SerieId
    -> Async<Result<InvoiceId * ReadModel.Issued, ProblemWith>>

Dónde TryBeginIssuanceWorkflow tiene esta forma:

type TryBeginIssuanceWorkflow =
    DraftId
    -> DocumentSerieId
    -> DateOnly
    -> Async<Result<DraftReadModel.IssuanceInProgress, ProblemWith>>

Si nos fijamos en los tipos vemos que en caso correcto nos devuelve DraftReadModel.IssuanceInProgress este ReadModel recoge el cambio de estado del que hablábamos antes. Si no se pasa por este método el ReadModel devuelto no estará en este estado.

Continuamos con AssignNumberWorkflow que es un comando que recive Serie. Este define como parámetro de entrada una condición clara: Se necesita un DraftReadModel.IssuanceInProgress (éste sólo se consigue tras iniciar la transacción anterior) para poder realizar este paso. No se guarda ningún dato referente al ReadModel, pero se pide como parámetro para que no podamos hacer la llamada de los sub-workflows de manera desordenada.

type AssignNumberWorkflow =
    DocumentSerieId
    -> DraftReadModel.IssuanceInProgress
    -> Async<InvoiceId>

IssueInvoiceWorkflow emitirá la factura a partir de un InvoiceId (que sólo se ha podido generar en el paso anterior el agregado Serie) y necesita el modelo del DraftReadModel.IssuanceInProgress porque en este caso sí, copia todos los datos del documento de negocio.

type IssueInvoiceWorkflow = InvoiceId -> DraftReadModel.IssuanceInProgress -> Async<ReadModel.Issued>

CurrentDate sólo nos devuelve la fecha oficial de la Península Ibérica cuando se hace la emisión de la factura. Es de facto un dato con implicaciones.

type CurrentDate =
    unit -> DateOnly

Tanto TryCommitIssuanceWorkflow como TryRollbackIssuanceWorkflow ya los conocemos bien, pero los mostramos aquí para mostrar que también piden el DraftReadModel.IssuanceInProgress lo que hace que sólo sean llamables en una transacción. En este caso tampoco se guarda ningún dato del modelo de lectura.

type TryCommitIssuanceWorkflow =
    (DraftId * DraftReadModel.IssuanceInProgress)
    -> InvoiceId
    -> DateTimeOffset
    -> Async<Result<DraftReadModel.Issued, ProblemWith>>
type TryRollbackIssuanceWorkflow =
    DraftRollbackError 
    -> (DraftId * DraftReadModel.IssuanceInProgress)
    -> Async<Result<DraftReadModel.Open, ProblemWith>>

Con todo ello podemos componer fácilmente un workflow de alto nivel a partir de sus dependencias, que no son más que workflows más sencillos. En la práctica, estos workflows se materializan como llamadas a los métodos públicos que expone cada agregado.

En nuestro caso, seguimos el patrón Service por agregado, tal y como propone el Equinox Programming Model.

Estos Service se resuelven mediante la inyección de dependencias de ASP.NET Core, lo que nos permite mantener los handlers de los endpoints limpios, declarativos y, al mismo tiempo, expresivos.

No incluimos el código de los controladores en este artículo porque alargaría innecesariamente el contenido. Lo dejamos como material para una futura entrega.

Conclusión

F# nos permitió convertir requisitos legales en tipos compilables, y esa diferencia fue decisiva.
Cuando un error puede convertirse en una sanción económica real, la confianza en el diseño deja de ser un lujo y pasa a ser una necesidad.

Muchas de las ideas que he aplicado aquí no son nuevas ni mías.
El énfasis en el dominio, en hacer que los estados erróneos sean irrepresentables y en modelar workflows explícitos está profundamente influenciado por el trabajo de Scott Wlaschin, especialmente en Domain-Driven Design Made Functional.
Del mismo modo, la forma de razonar sobre el comportamiento del sistema a través de eventos, y de separar decisión y evolución del estado, bebe directamente del trabajo de Jérémie Chassaing sobre Event Sourcing y el patrón Decider.

Nada de esto habría sido práctico sin herramientas que lo hagan viable en sistemas reales.
En nuestro caso, Equinox (y Propulsion) con MessageDb (gracias a Einar Norðfjörð) nos dio el andamiaje necesario para llevar estas ideas a producción de forma segura, explícita y mantenible. Mi agradecimiento sincero a sus creadores por demostrar que Event Sourcing en F# no es solo elegante, sino también industrial.

Sinceramente, sin un lenguaje como F#, con un sistema de tipos tan expresivo y una semántica tan alineada con el dominio, habría tenido mucha menos confianza implementando requisitos legales tan sensibles.

A la comunidad F#: gracias por compartir conocimiento, patrones y ejemplos reales.
Este artículo es, en gran medida, una consecuencia directa de haber aprendido de vosotros.

En un sistema regulado, eso no es solo una decisión técnica.
Es una forma de dormir tranquilo… y de seguir confiando en este lenguaje y en esta comunidad.