Start With One Table¶
You have been circling Event Sourcing for a while now: long enough to be convinced by the idea, not long enough to have built anything with it. Today you finally stopped reading and picked one table to start with, the one holding who pays you for which plan. Which is when a colleague asks you why. The table works. The reports run. Nobody has complained. And when you are done, it holds the same values it held that morning, so from the outside, you spent an afternoon achieving nothing.
Eighteen months later, your product owner turns up with a question nobody could have answered before, and you have the answer in twenty minutes, because of what you did that afternoon. This post is both halves of that: first the work that looks like wasted time, in full, with the code, and then what it was for. Everything in it is invented, from the company to the customers to the numbers, but the code is real, and it ran exactly as shown.
The Table That Works Fine¶
Here is the table, in SQL Server. These are customer subscriptions in the billing sense, not subscriptions to a stream of events. One row per paying customer, holding the plan they are on and what it costs them. Nothing in it is unusual, and if your own looks roughly like this, that is the point. Two dozen subscriptions sit behind the output further down, sixteen of which existed on the day of the switch; the rest started in the eighteen months after. They are made up as well, and generating them is not part of this post, because what matters is the shape of what comes out rather than the rows that went in.
CREATE TABLE dbo.subscriptions (
id NVARCHAR(64) NOT NULL PRIMARY KEY,
customer_id NVARCHAR(64) NOT NULL,
tier NVARCHAR(32) NOT NULL,
cadence NVARCHAR(16) NOT NULL,
amount INT NOT NULL,
status NVARCHAR(16) NOT NULL,
started_on DATE NOT NULL,
canceled_on DATE NULL
);
Eight columns, and every one of them earns its place. You can look up a subscription by its ID in a millisecond, count the active ones, sum the monthly revenue, list everyone who left last quarter. For the questions this table was built to answer, there is nothing better.
Now take any two customers who are both on pro. One of them worked their way up from basic. The other came down from enterprise. Both rows say tier = 'pro', and nothing in the table tells them apart – because when the plan changed, the application ran an UPDATE, and the previous value stopped existing at that moment.
You could add a column. A previous_tier, maybe a tier_changed_on. That answers the next question and only the next one, and it answers it from the day you deploy it: for everything before, the column is NULL. A year later somebody asks whether customers who switch from yearly to monthly billing leave more often, and you are back where you started, adding another column that begins empty.
The table is not wrong. It is just the only thing you have, and it holds exactly one version of the truth: the current one.
Before You Touch Anything¶
The usual objection arrives here, and it is fair: Event Sourcing sounds like a decision about the whole system, and nobody rewrites a working system on a hunch. We have described four ways to migrate, and they range from a big bang to taking one bounded context at a time. This is smaller than all of them, and it is not a migration at all.
The plan: the subscriptions table keeps its schema and its place. The write path in front of it starts recording what happens as events, and a projection builds the table back out of those events. Everything that reads the table carries on, unaware. Nothing is cut over, and the events become a second source that can answer things the table cannot.
One assumption is doing work there, so name it now: the writes have to go through one place. If four different jobs write into that table directly, all four have to move at once, and the afternoon stops being an afternoon. When that is the situation, it is not an argument against the idea – it is the sign that this is the wrong table to start with. Pick one whose writes already run through a single service.
Three things need to be clear before any code, because all three are easy to get backwards.
The events do not go into your relational database. The previous post made that case at length: PostgreSQL is not an event store – not because the table does not work, but because append-only storage, gap-free ordering and being notified of new events as they arrive are guarantees you would have to build and then own yourself. Events go into an event store. The subscriptions table becomes a read model, and that is a job a relational database is genuinely good at: as that same post puts it, looking up current state by a known key is its home ground.
Rebuilding the table is not the goal, it is the proof. We have argued that your aggregate is not a table – meaning the write side should not be shaped like a row just because the read side is. That is not what happens here. Eight columns answering one question is a perfectly good read model, and the read model zoo says as much: for lookups by a known key, the SQL table earns its place. Reproducing it exactly is how you show that nothing was lost.
And small scope is not the same as diluted rules. "Start small" gets misread as "take the parts you like". Everything below holds without exception: events are immutable, they are the source of truth for the write path, and the read model is derived, never edited. What shrinks is how much of your system takes part – not which rules apply to the part that does.
What Actually Happens to a Subscription¶
Start by not looking at the columns. Ask what people say happens to a subscription, in the words they use in a meeting: somebody signs up, somebody moves to a bigger plan, somebody moves back down, somebody switches from monthly to yearly, somebody pauses over the summer, somebody leaves.
That list is the event model, and it maps onto the columns only loosely. That is the useful part. Naming events beyond CRUD is the difference between a log of what the database did and a log of what the business did – and it is very concrete here, because tier is one column, while moving up and moving down are two different things that happen for two different reasons.
Written down, that becomes eight event types. One of them was not in the meeting: Imported, which only makes sense in a moment and which the next section is about.
public static class EventTypes
{
public const string Imported = "io.example.billing.subscription-imported";
public const string Started = "io.example.billing.subscription-started";
public const string Upgraded = "io.example.billing.subscription-upgraded";
public const string Downgraded = "io.example.billing.subscription-downgraded";
public const string BillingCycleChanged = "io.example.billing.subscription-billing-cycle-changed";
public const string Paused = "io.example.billing.subscription-paused";
public const string Resumed = "io.example.billing.subscription-resumed";
public const string Canceled = "io.example.billing.subscription-canceled";
}
Each event carries what happened, including what it replaced:
public record SubscriptionStarted(
string CustomerId, string Tier, string Cadence, int Amount, DateOnly StartedOn);
public record SubscriptionUpgraded(
string Tier, string PreviousTier, int Amount, int PreviousAmount, DateOnly EffectiveOn);
public record SubscriptionDowngraded(
string Tier, string PreviousTier, int Amount, int PreviousAmount, DateOnly EffectiveOn);
public record SubscriptionCanceled(string Reason, DateOnly CanceledOn);
// SubscriptionBillingCycleChanged carries the previous cadence and amount in the same
// way. SubscriptionPaused and SubscriptionResumed carry only a reason and a date -
// there is no previous value to keep, because pausing replaces nothing.
Splitting one plan change into two types is the safer direction – fine-grained events can always be read as a coarser view later, while a single SubscriptionTierChanged could never be split apart afterwards. What is genuinely irreversible is what you leave out: everything you do not write down is gone for good, and PreviousTier is a field you cannot add retroactively to events already written. That is the decision being made here, and it is worth making deliberately rather than by omission.
The Day You Switch Over¶
Now the part most write-ups skip, and the one that decides how honest the rest of this is.
Your table already has rows in it, and there are no events behind them. A subscription that started in 2024 was upgraded, downgraded and paused long before you started this, and each of those steps was an UPDATE that overwrote its predecessor. You cannot backfill what the table no longer contains. What you can do is write down, once, what you actually know:
public record SubscriptionImported(
string CustomerId, string Tier, string Cadence, int Amount, string Status,
DateOnly StartedOn, DateOnly ImportedOn);
One event per row, holding the state on the day you switch over and nothing about how it came about. Giving it its own type rather than dressing it up as a subscription-started matters: every later reader can tell an inherited state from a real beginning, and nobody ends up building a statistic on a start date that was never observed.
Three consequences follow, and all three belong in the plan.
Your history begins on migration day, not at the beginning of time. Any question you ask later reaches back to that day and no further. That is exactly the property you just held against adding a column – with one difference, which is the whole point: the column answers the question you thought of today, while the events also answer the ones you have not thought of yet. Both start collecting now. Only one of them stops being useful when the question changes.
Anything you measure will be skewed for a while. A cancellation whose downgrade happened before migration day looks, in the events, like a cancellation out of nowhere. Every rate you compute in the first months understates what is really going on, and it keeps understating it until the oldest events are older than the pattern you are looking for.
And you do not have to take everything. In the example, only the subscriptions still running are imported – the ones already canceled are finished, and nothing further will happen to them. That is a scope decision with a visible consequence: the rebuilt table below has fifteen rows where the original had sixteen, because sub-0004 was canceled back in 2024 and therefore never received an event. In a real system you would go one of two ways: import those rows too, as one closing event each, or point the projection at the existing table instead of a fresh one, so the finished rows simply stay where they are.
The Write Path¶
Everything from here runs against a local instance of EventSourcingDB. Running it for development is a single docker run with a handful of flags – an API token, a temporary data directory, and HTTP instead of HTTPS so you do not have to deal with certificates first. The API is HTTP, so any language that can make a request is already a client; the .NET SDK used below is one dotnet add package EventSourcingDb away and saves you writing that plumbing yourself. Either way you can be writing your first event within minutes of deciding to try.
The write path has two halves, following the shape we have described as decide, evolve, repeat. Evolve folds the events of one subscription into whatever state a decision needs:
public static SubscriptionState Evolve(SubscriptionState state, Event @event) =>
@event.Type switch
{
EventTypes.Imported => Apply(state, @event.GetData<SubscriptionImported>()!),
EventTypes.Started => Apply(state, @event.GetData<SubscriptionStarted>()!),
EventTypes.Upgraded => Apply(state, @event.GetData<SubscriptionUpgraded>()!),
EventTypes.Downgraded => Apply(state, @event.GetData<SubscriptionDowngraded>()!),
EventTypes.BillingCycleChanged => Apply(state, @event.GetData<SubscriptionBillingCycleChanged>()!),
EventTypes.Paused => state with { Status = SubscriptionStatus.Paused },
EventTypes.Resumed => state with { Status = SubscriptionStatus.Active },
EventTypes.Canceled => state with { Status = SubscriptionStatus.Canceled },
_ => state
};
Decide takes that state and a command and returns the events it produces. It builds the subject (the path an event is stored under) and dispatches to one method per command:
public static IReadOnlyList<EventCandidate> Decide(
SubscriptionState state, Command command, string subscriptionId)
{
var subject = $"/subscriptions/{subscriptionId}";
if (state.Status == SubscriptionStatus.Canceled)
{
throw new InvalidOperationException("A canceled subscription can not be changed.");
}
if (command is not (Command.StartSubscription or Command.ImportSubscription)
&& !state.DoesExist)
{
throw new InvalidOperationException("This subscription does not exist yet.");
}
return command switch
{
Command.ChangeTier changeTier => DecideChangeTier(state, changeTier, subject),
// ... one per command
};
}
And here is the one place in the whole exercise where you have to say something the table never said:
private static IReadOnlyList<EventCandidate> DecideChangeTier(
SubscriptionState state, Command.ChangeTier command, string subject)
{
if (command.Tier == state.Tier)
{
return [];
}
var amount = PriceList.AmountFor(command.Tier, state.Cadence);
var isUpgrade = PriceList.RankOf(command.Tier) > PriceList.RankOf(state.Tier);
return
[
isUpgrade
? new EventCandidate(
Source, subject, EventTypes.Upgraded,
new SubscriptionUpgraded(command.Tier, state.Tier, amount, state.Amount, command.EffectiveOn))
: new EventCandidate(
Source, subject, EventTypes.Downgraded,
new SubscriptionDowngraded(command.Tier, state.Tier, amount, state.Amount, command.EffectiveOn))
];
}
PriceList.RankOf looks the tier up in an ordered array of free, basic, pro and enterprise, and PriceList.AmountFor returns the price in cents, where a yearly plan costs ten monthly ones rather than twelve. That is why pro yearly shows up as 29000 further down. One comparison decides whether this was a step up or a step down, and that is the entire difference. Your application already knows it at that moment: the customer just clicked something, or a sales rep just agreed to something. Today it knows, writes an UPDATE, and forgets. Now it knows and says so.
The handler ties the halves together – load the events for one subscription, decide, append:
public static async Task HandleAsync(Client client, string subscriptionId, Command command)
{
var subject = $"/subscriptions/{subscriptionId}";
var state = SubscriptionState.Initial;
string? lastEventId = null;
await foreach (var @event in client.ReadEventsAsync(subject, new ReadEventsOptions(Recursive: false)))
{
state = Subscription.Evolve(state, @event);
lastEventId = @event.Id;
}
var newEvents = Subscription.Decide(state, command, subscriptionId);
if (newEvents.Count == 0)
{
return;
}
var precondition = lastEventId is null
? Precondition.IsSubjectPristinePrecondition(subject)
: Precondition.IsSubjectOnEventIdPrecondition(subject, lastEventId);
await client.WriteEventsAsync(newEvents, [precondition]);
}
The precondition at the end is the part people skip, and it deserves a moment. It says: append these events only if this subscription has not changed since I read it. Without it, two concurrent plan changes both read the same state and both succeed – and unlike the table, where the second UPDATE would simply win, here you keep both events, each claiming the same predecessor. Nothing is lost and the history is wrong, which is worse: every answer derived from it later inherits the error.
Three practical notes for C#. The SDK's Client lives in EventSourcingDb, while everything else used here lives in EventSourcingDb.Types: Event, EventCandidate, ReadEventsOptions, ObserveEventsOptions, Bound, BoundType and Precondition. You need both using directives. The Source in the code above is the CloudEvents field, not the "source of truth" sense used earlier in this post, and it has to be a URI reference rather than a free-form label. And the Client is constructed with JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; without it, your C# property names go into the store as they are written, and everyone reading those events later gets PreviousTier where they expected previousTier.
The Table, Rebuilt¶
The projection has to do two things a one-off replay does not: survive a restart, and keep up afterwards. Both are ordinary requirements for any read model, and building event handlers covers them in general. Together they cost roughly thirty lines: a second table, two small helpers to read and write the checkpoint, and one branch on the options. Everything below ran against SQL Server 2025 through Microsoft.Data.SqlClient, and the output in this post is what that run printed.
Surviving a restart means remembering where you got to:
CREATE TABLE dbo.projection_checkpoint (
name NVARCHAR(64) NOT NULL PRIMARY KEY,
last_event_id NVARCHAR(64) NOT NULL
);
Keeping up means observing rather than reading, starting after whatever you handled last:
var lastEventId = await ReadCheckpointAsync(connection, ProjectionName);
var options = lastEventId is null
? new ObserveEventsOptions(Recursive: true)
: new ObserveEventsOptions(Recursive: true, LowerBound: new Bound(lastEventId, BoundType.Exclusive));
Console.WriteLine(lastEventId is null
? "Starting from the beginning of the history."
: $"Resuming after event {lastEventId}.");
await foreach (var @event in client.ObserveEventsAsync("/subscriptions", options, cancellationToken))
{
await ApplyAsync(connection, @event);
await WriteCheckpointAsync(connection, ProjectionName, @event.Id);
}
WriteCheckpointAsync is the same UPDATE-then-INSERT shape as the one below, against projection_checkpoint.
ObserveEventsAsync replays the history first and then stays on the connection for whatever arrives next, so there is no catch-up path, no live path, and no handover between them to get wrong.
Applying an event is one statement per type. The subject carries the ID, and delivery is at-least-once, because a crash between applying an event and storing the checkpoint replays that event. So each statement has to end in the same state whether it runs once or twice:
private static async Task ApplyAsync(SqlConnection connection, Event @event)
{
var subscriptionId = @event.Subject[(@event.Subject.LastIndexOf('/') + 1)..];
switch (@event.Type)
{
case EventTypes.Imported:
{
var data = @event.GetData<SubscriptionImported>()!;
await UpsertAsync(connection, subscriptionId, data.CustomerId, data.Tier,
data.Cadence, data.Amount, data.Status, data.StartedOn);
break;
}
case EventTypes.Started:
{
var data = @event.GetData<SubscriptionStarted>()!;
await UpsertAsync(connection, subscriptionId, data.CustomerId, data.Tier,
data.Cadence, data.Amount, "active", data.StartedOn);
break;
}
case EventTypes.Upgraded:
{
var data = @event.GetData<SubscriptionUpgraded>()!;
await ExecuteAsync(connection,
"UPDATE dbo.subscriptions SET tier = @tier, amount = @amount WHERE id = @id",
Text("@tier", data.Tier, 32), Number("@amount", data.Amount),
Text("@id", subscriptionId, 64));
break;
}
// Downgraded is identical to Upgraded. Canceled sets status and canceled_on,
// Paused and Resumed set status, BillingCycleChanged sets cadence and amount.
default:
throw new InvalidOperationException(
$"Unknown event type '{@event.Type}' at event {@event.Id}.");
}
}
That default arm matters more than it looks. Without it, an event type this projection does not know yet (one that was deployed slightly ahead of it, which happens) would be skipped silently while the checkpoint moved past it, and it would never be applied. Refusing to continue is the safer default for something whose whole claim is that it can be rebuilt from the events.
Text and Number build a typed SqlParameter, and UpsertAsync is an UPDATE followed by a conditional INSERT; both are worth a closer look, but they can wait until the thing has run.
Note instead that Downgraded does exactly what Upgraded does – in the read model the two collapse back into one UPDATE, precisely as before. The distinction lives in the events, and the table is none the poorer for it.
Start it up on the day of the switch, and the fifteen import events produce this:
Starting from the beginning of the history.
id customer tier cadence amount status started canceled
-----------------------------------------------------------------------------------
sub-0001 cust-0001 pro yearly 29000 active 2024-01-15
sub-0002 cust-0002 pro monthly 2900 active 2024-02-03
sub-0003 cust-0003 enterprise yearly 99000 active 2024-02-19
sub-0005 cust-0005 pro yearly 29000 active 2024-03-22
sub-0006 cust-0006 enterprise yearly 99000 active 2024-04-11
...
sub-0015 cust-0015 basic monthly 900 paused 2024-12-01
sub-0016 cust-0016 pro monthly 2900 active 2025-01-08
That is the table as it stood that morning, rebuilt from events: the fifteen imported rows, the paused one still paused, every value where it was. Missing is only sub-0004, the row that was already canceled and that we chose not to import. Stop the process and start it again, and it reports Resuming after event 14 (IDs start at zero, so that is the fifteenth) and applies nothing a second time. Send a command while it runs, and the row changes a moment later.
Apart from that one row you deliberately left behind, you have changed how the data gets into the table without changing what is in it. The reports, the billing job, the admin screen somebody wrote four years ago – all of them keep working, untouched and unaware.
Three things bite when you build this yourself, and none of them shows up until you do.
Typed parameters cut both ways. Text and Number exist so that an NVARCHAR column gets an NVARCHAR parameter instead of whatever AddWithValue infers from the value. But declaring a length means a value longer than the column is truncated silently, where AddWithValue would have sent it in full and let SQL Server reject it. Typed parameters are the better habit, not a free one.
T-SQL has no upsert. There is no ON CONFLICT, and MERGE carries enough well-documented caveats that an UPDATE followed by a conditional INSERT is the safer habit:
UPDATE dbo.subscriptions
SET customer_id = @customerId, tier = @tier, cadence = @cadence,
amount = @amount, status = @status, started_on = @startedOn
WHERE id = @id;
IF @@ROWCOUNT = 0
INSERT INTO dbo.subscriptions
(id, customer_id, tier, cadence, amount, status, started_on, canceled_on)
VALUES (@id, @customerId, @tier, @cadence, @amount, @status, @startedOn, NULL);
And between those two statements there is a gap. Another writer could slip into it. The projection is meant to be the only thing writing this table, which is what makes it a read model, but nothing in the code enforces it, and starting the process twice is enough to break the assumption. If you need it to hold under concurrency, put the pair in a transaction and take the lock on the UPDATE itself, with WITH (UPDLOCK, HOLDLOCK). A transaction alone does not help, because an UPDATE that matches no row takes no lock that would stop a second inserter.
And that is where the afternoon ends. Everything works exactly as before. This is the moment that colleague asks what you have been doing all afternoon, and the answer is: nothing you can see.
And Then Nothing Happens for Eighteen Months¶
It is worth being blunt about this stretch, because it is the reason most people never try.
For a long time, the events do nothing for you. They accumulate. The projection runs. The table looks the way it always did, and every feature you build in that period you would have built the same way anyway. If somebody asked you to justify the afternoon, you could not – not with a number, not with a demo. The only thing you have bought is that the history is no longer being thrown away, and nobody misses history until they need it.
There is a real commitment in there, and it deserves saying out loud. Events are permanent by design, and what you write is as much a one-way door as what you leave out. Trying the exercise on a throwaway instance costs nothing; the day you point it at production data, you are deciding to keep that history for good. That is the right call for subscriptions and the wrong one for a job queue. Event Sourcing is not for everyone, and the question that sorts it quickest is whether anyone will still want to know about this a year from now.
For a subscription, obviously yes. Which is why, eighteen months later, this happens.
The Question Your Product Owner Brings¶
The scene is invented, but you have had some version of this conversation:
"Do people give us any warning before they cancel? I keep feeling like the ones who leave get quieter first, but I have nothing to show for it. Could we see whether they downgrade before they go?"
Under the old table this is a project. There is no column for it, and adding one now would start empty, so the answer would arrive next year at the earliest. With the events it is a second reader over the same history, for now a one-off report rather than a stored read model: no migration, no new column, nothing to wait for.
await foreach (var @event in client.ReadEventsAsync("/subscriptions", new ReadEventsOptions(Recursive: true)))
{
var subscriptionId = @event.Subject[(@event.Subject.LastIndexOf('/') + 1)..];
switch (@event.Type)
{
case EventTypes.Downgraded:
{
var data = @event.GetData<SubscriptionDowngraded>()!;
lastDowngrades[subscriptionId] = new LastDowngrade(
data.PreviousTier, data.Tier, data.EffectiveOn, data.PreviousAmount - data.Amount);
break;
}
case EventTypes.Upgraded:
// Going back up withdraws the signal, so it must not count as a warning.
lastDowngrades.Remove(subscriptionId);
break;
case EventTypes.Canceled:
{
var data = @event.GetData<SubscriptionCanceled>()!;
cancellationCount++;
if (lastDowngrades.TryGetValue(subscriptionId, out var downgrade))
{
warnings.Add(new Warning(
subscriptionId, downgrade.FromTier, downgrade.ToTier, downgrade.On, data.CanceledOn,
data.CanceledOn.DayNumber - downgrade.On.DayNumber, downgrade.AmountLost));
}
break;
}
}
}
That is the rule. Around it sits the usual scaffolding, a dictionary, a list, a counter, two records, a sort, a median, and the code that prints the table. Another sixty lines or so, none of it interesting. Here is what it printed:
12 subscriptions were canceled.
9 of them had stepped down to a cheaper tier first.
id from to downgraded canceled days lost
-----------------------------------------------------------------------
sub-0010 basic free 2025-06-01 2025-07-15 44 900
sub-0002 pro basic 2025-03-01 2025-05-14 74 2000
sub-0006 pro basic 2025-11-01 2026-01-20 80 20000
sub-0022 pro basic 2026-04-01 2026-06-30 90 20000
sub-0014 enterprise pro 2026-01-05 2026-04-10 95 70000
sub-0017 enterprise pro 2026-02-14 2026-05-30 105 70000
sub-0019 pro basic 2026-03-01 2026-06-15 106 2000
sub-0009 enterprise pro 2025-09-01 2025-12-31 121 7000
sub-0011 pro basic 2025-10-01 2026-02-28 150 2000
Median warning time: 95 days.
Shortest: 44 days. Longest: 150 days.
The figures come from invented data and a real business would find its own, so look at the shape rather than the numbers. Three quarters of the cancellations announced themselves first, and the median announcement came three months ahead – and that is with the first months of the window still distorted by the migration, as described above, so the real proportion is not lower than this.
Three months is not an analytics finding, it is a working window: long enough to call someone. And turn the same rule around, run it as a live projection instead of over the history, and it stops being a report – it becomes a list of customers who have stepped down and not yet left. That list is a queue somebody can work through.
Here is the part worth keeping. Nobody guessed the question. There is no tier_changes table somebody had the foresight to add, no analytics event somebody remembered to fire. The only foresight required was deciding that "moved down" and "moved up" are different things – which is not a prediction about churn, it is just refusing to flatten two events into one because the column was one column.
Why the Audit Log Wouldn't Have Helped¶
There is a version of this you may already have. Somewhere in most long-running systems sits a table called audit_log or activity or history, with a timestamp, a type, and a JSON blob. In almost every long-running codebase we have worked in, one has grown at some point – nobody plans it in the first design; it arrives around year two, when somebody needs to know who changed what.
The instinct is right, and the result is half of what you want. It is a log you can read, not one you can rebuild from. It records that a change occurred, in a shape chosen for a support ticket, not for reconstruction, and usually only for the tables somebody remembered to instrument. The current state still lives in the main table, written separately, so the two can drift and nothing notices. We have made the same argument from the other side in you don't need an audit log: a second write kept correct by discipline rather than by construction.
The difference fits in one sentence. The events are not a record of the write – they are the write. There is no second thing to keep in sync, because the table is derived from the events rather than maintained beside them. That is why the product owner's question could be answered from what was already there, rather than from whatever somebody had thought to log.
Two Rows That Look the Same¶
Remember the two customers on pro from the beginning, the ones the table could not tell apart. In January 2026, ten days apart, this system produced exactly that pair.
sub-0014 came down from enterprise on the fifth, and canceled ninety-five days later; it is the fifth line in that list. sub-0023, one of the subscriptions that started after migration day, came up from basic on the fifteenth and never appears in that list at all, because it never canceled. That absence is the entire difference – and it was there to be seen long before the cancellation: for the eighty-five days the two overlapped, the standing list of customers who had stepped down and not yet left held one of them and not the other, while the table showed the same word for both. The difference was never missing from the business. It was missing from the table, and only because a column holds one value at a time.
You do not have to fix that everywhere, and you do not have to fix it today. Pick the one table where the history would have told you something. Spend an afternoon moving its write path onto events and rebuilding the table from them, and accept that for a while it will look like you achieved nothing – because visibly, nothing is what you achieved. What you actually did was stop throwing away the part nobody misses until they need it.
The post on PostgreSQL ended by saying that trying EventSourcingDB is an experiment rather than a migration: something you can undo by deleting a container. This is what that experiment looks like on one table, and installing it and writing your first events are where it starts.
The step after that is not technical. Go and ask the people who work with that table every day which questions they gave up on – not the ones on the roadmap, the ones they stopped raising because the answer was always "we don't store that". You will hear one within a minute, and it will tell you which table to pick better than any rule we could give you. We would like to hear what you find: write to us at hello@thenativeweb.io. The questions people stopped asking are the most interesting thing about any system, and they are almost never written down anywhere.