Complying with the Law with F# and Event Sourcing (part 2): when events don't know what day it is
by Pedro León
In a previous article we explained how we modeled the lifecycle of a draft invoice using F# and Event Sourcing, and how the type system helped us turn legal requirements into code that simply doesn't compile if the requirement isn't met. This article is the natural continuation of that one: a case that looks small on the surface, but forced us to rethink part of the design so we wouldn't repeat the same mistake twice.
Strings Digital Products
We are a software development studio with over 20 years of professional experience based in Madrid, specializing in critical systems, event‑driven architectures, and domain‑driven design.
We work with clients from anywhere in the world, building robust, maintainable digital products that are designed to last.
You can read the spanish version of this article here
The requirement, this time, was easy to state: a draft's operation date can't be later than its issuance date. Implementing it properly, however, turned out to be less straightforward.
The legal origin of the requirement
This requirement isn't our own free interpretation: it comes directly from the Invoicing Regulation (Real Decreto 1619/2012). Its Article 9 ("Deadline for issuing invoices") states that invoices must be issued at the moment the operation takes place (when the recipient is an individual) or, at the latest, before the 16th day of the month following the one in which the tax point occurred (when the recipient is a business or professional).
In other words: an invoice cannot document an operation that, from a temporal standpoint, hasn't happened yet. The operation date can never be later than the issuance date.
That this is not a minor nuance is also confirmed by the Verifactu system itself, which validates it explicitly as a system error: Error 1112 ("Issuance date later than the current date") is triggered precisely when an invoice is issued with a date inconsistent with the actual moment of issuance. It's, in practice, the same check we wanted to guarantee in our own system — only we wanted to enforce it earlier, at the draft stage, not only at the final moment of issuance.
The problem: the clock doesn't live where we thought it did
Our Draft aggregate is folded from events: Placed, IdentityVerified, BeginIssuance, IssuanceCommited... Equinox caches the result of that fold, because assuming it is pure and deterministic is exactly what lets it avoid re‑reading the entire stream every time.
The problem showed up as soon as we wanted to ask "is the operation date no longer in the future?". That question depends on the clock, not on the events. And the clock changes without any event being fired. If that answer leaked into the persisted state — even as a field computed just once — we'd end up with a draft that could get "frozen" showing a stale answer until an unrelated event happened to touch the stream again. That's exactly the kind of silent bug this approach is supposed to prevent, not cause.
We ruled out two quick fixes for the same underlying reason, even though they looked different:
- Computing the date check in the read endpoint and, separately, repeating the same computation in the write‑side decider. Two implementations of the same criterion are, sooner or later, two different criteria.
- Storing the result of that comparison as part of the persisted state. That's exactly what Equinox caches, so we would have baked the clock into something that must stay pure.
How did we make sure read and write could never disagree?
The answer that convinced us was to stop treating this as a discipline problem ("remember to always call the same function") and turn it into a type problem instead.
We split the state into two distinct types:
type UncheckedDraftStatus =
| AwaitingIssuance of AwaitingIssuance
| IssuanceInProgress of IssuanceInProgress
| Issued of Issued
// There is no "Issuable" case here. It cannot be constructed.
type CheckedDraftStatus =
| NotIssuable of NotIssuable
| Issuable of Issuable
| IssuanceInProgress of IssuanceInProgress
| Issued of Issued
UncheckedDraftStatus is the only thing Fold.evolve produces, and it is deliberately blind to the clock: it has no slot to store "is this issuable right now", because answering that question isn't its job. The only way to obtain a CheckedDraftStatus.Issuable is to go through a single function:
let checkIssuabilityTemporalConditions
(currentDate: System.DateOnly)
: UncheckedDraftStatus -> CheckedDraftStatus =
...
And that function is called both by Decide.tryBeginIssuance (the write side) and Queries.findWithEtag (the read side), always with a freshly captured date, never from inside Fold.evolve itself. It's not that read and write "should" agree by convention — since it is literally the same function, there is no way for them to disagree. The compiler becomes the guarantor of this invariant, not team discipline or a test suite.
The diagram below summarizes this state machine: on the left, the persisted state cached by Equinox, driven only by events; on the right, the transient, never‑cached result that only exists the moment someone asks with a concrete date in hand.
One reason shouldn't hide the other
While making this change we ran into a second, more subtle problem. Before this version, a "not issuable" draft could only have one reason: unverified parties, or a future operation date, never both. But in practice a draft can have both problems at once, and reporting only one hid relevant information from whoever integrates with our API.
The fix was, once again, a matter of types: instead of a DU with a fourth mutually exclusive case, NotIssuable now carries both facts as independent fields, always present:
type NotIssuable =
{ InvoiceKind: InvoiceKind
PartiesVerification: PartiesVerification
OperationDateVerification: OperationDateVerification }
PartiesVerification and OperationDateVerification are two orthogonal facts. When both fail, both are reported at once, in the new notIssuableReasons field we now expose in the API. No extra if was needed to achieve this: there simply stopped being a place where one could hide the other.
What we take away from this case
Seen from the outside, this feature is small: one new field, no breaking changes, three lines in the changelog. But the path to get there reaffirms something we already sensed from the previous article: in an Event Sourcing system, the question "what can the fold cache?" is just as important as "what does the law say?". Making incorrect states unrepresentable — Scott Wlaschin's idea, which we already cited last time — doesn't apply only to domain data; it also applies to the aggregate's own lifecycle and its relationship with time.
When a requirement carries legal implications, it's not enough for the code to "usually" do the right thing. We'd rather have the compiler refuse to let us do the wrong thing. Once again, without a language like F# and without the discipline that comes with properly applied Event Sourcing, this guarantee would have been much harder to sustain over time.