In part thirteen of this event sourcing series, we expand on set-remove-based bi-temporal events. In certain scenarios, we need to be able to undo events – typically because a user made a mistake. I’ll show you two approaches to solve this issue, why the “obvious” simple approach fails, and why we prefer one over the other.
The problem
In our time-tracking application, an employee can state that they started working on a task (at the current time or at a manually chosen time): E.g. “At 08:03, I started working on planning the garden arbour project.” The result is a timeline of events stating what the employee worked on. When choosing a manual time, the employee can specify the task being worked on in the past (or the future).
Sometimes, employees make mistakes and enter wrong data. Let’s look at the following (abstract) example – let’s assume, the numbers are tasks being worked on.

The tasks being worked on are 42, 17, and 67. Now, setting 17 was a mistake. The employee never started working on task 17.
The wrong solution
At first, you might think that we can simply override the event setting task 17 by creating a new event stating that we (continued to) work on task 42 with the same timestamp as the event that set task 17.

This gives us the correct timeline for the moment. But it does not work correctly when the employee remembers that they worked on task 13 after working on task 42:

Now, we have a corrupted timeline because they didn’t work on task 42 after task 13, but they worked on task 13 for the full time between task 42 (black) and task 67. So overriding the wrong event doesn’t work in this case.
Why not compensate the wrong event?
In an earlier post on this series, I showed you that we can compensate events – as if they had never existed. That solves the problem, but not in a very nice way. The strength of event sourcing is that events tell the whole story – what happened and why it happened. If we would simply compensate the event, we would lose the meaning of an undo. We could, of course, add a compensation reason to the data table in SQL Server that holds additional information, but it felt like a misuse of the compensation feature.
Mark events as undone
Regarding the timeline of tasks being worked on, we chose the following approach. We introduced two new columns on the table holding the events: undone and undoneAt. The first holds the operation ID of the operation/command that executed the undo. The second holds the timestamp of the undo operation. So, it’s the “same” solution as compensation, but without losing meaning.
Now, when we read events from the storage, we simply ignore events that are undone – like we ignore compensated events.
If you are interested in the details, take a look at the deep dive section at the end of the post.
A real undo
We have another solution to this problem that we use for another feature in our system. Employees can track the cost centre they work in. They also have the possibility to temporarily work for another cost centre. This feature works conceptually the same as the one that states which task the employee is currently working on. There, we went with another solution. This feature was implemented before the task-tracking feature. So this solution came first. And we saw that it introduces some unwanted complexity. At the time, we didn’t see a simpler solution, so we went with it, and it still exists in our codebase.
In addition to Set and Remove events, we also know Undo events. We can tell the projector to undo a certain event. For the projector to know what event to undo, we decided that an undo events undoes the event with the same effective timestamp and the latest application timestamp that is smaller than or equal to the application timestamp of the undo event.
I think you understand why we prefer the above solution with the undo column to this one after reading the prior sentence. 😅
This solution works, but it is hard to understand and a bit harder to implement correctly.
What makes things even more complicated is that we need a deduplication ID to ensure we don’t undo too many events when an undo event is duplicated due to a retry triggered by a failure in our distributed system.
In an earlier post, we discussed duplication, and we decided that we accept duplicated events because the duplicates have no effect – except for undo events. A past design decision is catching up to us now.
Summary
Undoing events in bi-temporal event sourcing is tricky. I showed you two solutions to this problem, and a little bit of history of our design journey through a working solution that we replaced with a simpler solution over time.
That’s the conceptual part of the post. Stick around if you want to go deep into the code.
Anyway, I wish you a nice summertime (or wintertime if you are in the southern hemisphere) – there will be a summer break in my posting.
Deep dive
In this deep dive, we take a look at the implementation of the task tracking shown above (the one with the undone columns in the database). While the solution with the real undo is surely more interesting, we will go with this solution from now on. Boring software is simple software, and simple software is easy-to-maintain software.
These are the types that define the events related to task tracking. We call these tasks default tasks because they state what you work on by default unless you override it with a specific entry on what you worked on. An employee can start a single or multi-action (working on several tasks at once), or stop working on a task (then there is no current task).

Then we need to instruct the event projector:

The operation/command executing the undo:

The getZoneIfEmployee and getHr functions are injected because they get data from another subsystem. The composition root bridges these subsystems so that we can access data from here that belongs to another subsystem.
The injected undo function is used to persist the fact that an event is marked as undone.

We make sure to undo that event with the latest application of all events at the specified effective timestamp. Once employees start to correct their timelines, they often use a trial-and-error approach, leading to a lot of events with the same effective timestamp. 🙈