Offline-First KMP · Part 14

Not Everything Deserves a Ledger

Cover Image for Not Everything Deserves a Ledger
Android
androidkmpoffline-firstevent-sourcingconflict-resolutionsync
·7 min read

We event-source the entities whose history is the product. The interesting group is the one in between.

TL;DR Event sourcing is usually discussed as an on/off switch for the whole app. In practice you decide it per operation. Our entities fall into three groups: ledger entities, where every write is an event; hybrid entities, where creating and deleting go through the plain sync queue but every edit goes through the ledger; and read-only lookups that no device writes. The hybrid group looks like an unfinished migration. It isn't: a row being created has no earlier version, so conflict detection has nothing to compare it against.

Part 14 of the Offline-First KMP series: field notes from a production winery app built on Kotlin Multiplatform (KMP), whose architecture Part 11 maps end to end. It assumes Part 13: you know what a version snapshot is and how the disjoint-column check works. This is the question that comes next, which nobody asks until they have built one of these: which entities actually deserve that machinery.

Three Entity Groups: Ledger, Hybrid, Read-Only

Our entities fall into three groups, and the group decides how a write reaches the server, if it reaches it at all.

Three entity groups, by write path
Ledger: six entitiesevery write is an event
Hybrid: eight entitiescreate and delete: sync queue
edit: event
Read-only: a few dozen lookup tablesserver-seeded, never written by a device

Ledger. Dockets, wines, vessels, bottle runs, docket analyses, and docket certifications. The client never uploads these rows directly at all. It records an event and paints a provisional projection so the screen updates instantly; the server's engine derives the authoritative row and syncs it back.

Hybrid. Growers, varietals, additive definitions, manufacturers, and the packaging types. You can create one of these offline and the sync layer uploads the row directly, the ordinary way, and you can delete one the same way. But you cannot edit one that way. A local edit is applied for the screen and then dropped before the wire; the edit travels as an event instead, so the server stays the authoritative writer for every change.

Read-only. Status tables, type lookups, the genuinely static rows. A few dozen of them, seeded for each workspace by the server, synced down, and never written by a device. Customising them for a winery is a server-side migration, not something the app does. The connector does not upload them and the server would refuse the row if it did. No events, no versions, and no conflicts, because the phone is never the writer.

Yes, creating and deleting catalog rows is the plain queue this series has spent several posts criticising, and we shipped it on purpose: last write wins only loses data when there is something to lose.

Why Hybrid Entities Split Create From Edit

The hybrid group is the one that raises eyebrows, so here is the actual reason, and it is not gradual migration.

Conflict detection works by comparison. An event carries a version snapshot of what the user was looking at, and on sync the server compares that snapshot against the current state. That comparison is the whole mechanism.

Now try to apply it to a row being created. There is no previous version. There is nothing on the server to compare the snapshot against, because a moment ago the row did not exist. The machinery is not expensive here, it is inapplicable. You are paying for a comparison that has no second operand.

The failure modes differ too, and that is the part that decides the trade:

Two people, one grower, both offline
Both create it
plain sync queue
both uploads succeed: two growers
a duplicate anyone can see
Visible, and a human can fix it.
Both edit it
if it rode the queue too
last write wins: one edit is gone
no duplicate, no flag, no log
Silent data loss.

So the hybrid group gives each operation the mechanism its failure calls for. Creating a grower rides the plain queue, because its worst case is a duplicate somebody will see. Editing one goes through the ledger, because its worst case is an edit nobody will ever know was lost.

Event Sourcing per Operation, Not per Entity

That is the transferable part, and it is why "do you event-source everything?" is the wrong question.

Two questions decide a path, and they are independent:

  1. Will somebody need to audit this operation later? Grapes arriving as a docket is part of the winery's story; someone will eventually ask who moved that wine and when. Adding a row to the varietal list is data entry, and nobody has ever asked me to audit the history of a lookup table.
  2. Can this operation destroy work somebody else did? Edits can. Creations mostly cannot; the worst case is a duplicate.

Put together, the two answers pick the path:

Two questions, three paths
Audited later, and can destroy workledger
Creating can't destroy work, editing canhybrid
Not audited, can't destroy workplain sync queue

The middle row is our hybrid group: common, and rarely modelled. The bottom row is not a compromise, it is correct; none of our entities needs it, because the lookups skip the question entirely: no device writes them.

A ledger is not free: every event type costs a payload, a validator, a processor and a test. Spend that on renaming a bottle size and event sourcing earns its reputation for being heavy.

Doesn't the Event Ledger Slow Down as It Grows?

No, and the reason is the sync layer. The server applies each event once, when it arrives, and the phone reads state from rows that are already computed:

One event, from arrival to screen
Event arrives
appended to the ledger
Engine applies it once
writes the entity rows
On the server
Sync streams the rows
to every phone
Phone reads the rows
plus its own
unsent events
Replay the ledger
never on a device

No device ever replays the ledger to learn what is in a tank. The log is left with two jobs: the timeline, and a conflict check that only looks back to the version the user saw.

Declare Each Entity's Group in One Place

Once entities live in different groups, the group decides how every one of their writes travels. Yet no single place says which group an entity is in: it is spread over three lists that have to agree. Say next season you add barrel suppliers to the catalog, as a hybrid entity like growers:

Making barrel suppliers a hybrid entity
Three lists, kept by hand
each one edited separately
conflict check tracks its versions
upload connector accepts its creates
upload rule sends its edits as events
Miss one, and writes get refused.
One entry per table
the three lists are derived from it
barrel_supplier → hybrid
all three settings follow
a new table can't land half set up
Nothing to keep in step.

Forget the upload rule on the left, and the phone uploads direct edits that the server refuses. So if you build one of these, write each entity's group down once and derive the three lists from it, or at least add a test that fails when they disagree. It is the same idea as Part 16: when correctness depends on metadata staying in step, let the build check it, not the next person's memory.

The Takeaways

  • Event sourcing is a per-operation decision, not an architecture-wide one. Ask it per write, not per app, and expect the answer to differ between creating a thing and changing it.
  • Conflict detection needs something to compare against. On creation there is no previous version, so the machinery is not costly there, it is inapplicable. That alone justifies the hybrid group.
  • Match the mechanism to the failure mode. Duplicate rows and silent lost edits are different problems. Paying ledger prices to prevent duplicates is overkill; paying queue prices and losing edits is negligence.
  • Declare each entity's group in one place. Spread over several lists, it drifts; derive them from one declaration, so the next table you add lands in the right group.