An event-sourced inventory system for a disc golf shop, where every disc on the shelf is a one-of-one and the catalog rebuilds itself from an append-only ledger.

DiscRepublic sells disc golf discs, and its inventory problem is the kind most software refuses to believe in: almost nothing on the shelf is interchangeable. Two discs from the same mold come off the line at different weights, in different plastics, with different colour shifts and foil stamps. Players care about all of it. The thing for sale isn’t a SKU with a quantity; it’s that disc, the 168-gram one with the red prism foil.
Shopify, like every catalog, thinks in products and variants that exist in quantity. So getting a bin of forty discs online meant forty photographs, forty weigh-ins, and forty variants created by hand in the admin. Slow enough that new arrivals sat in bins instead of on the storefront, which for a shop that lives on drops is the same as not having them.
The 8Examples habit: describe the thing as what happens, over time, before building any of it. Here that meant Event Modeling, in the Adam Dymitruk and Martin Dilger tradition: lay the workflow out as a strip of notes. What the person sees. What they ask the system to do. What becomes true because of it. What the system shows next.
This is the model for the workflow that pays the rent, restocking a single disc:
Two of the six columns have no human in them, and that is the real payoff of drawing the model first: the automation fell out of the picture instead of being bolted on later. A background job is just another user. It reads a read model the way a person reads a screen, and it issues commands the way a person taps a button. Same front door, same rules.
The build is deliberately boring: a Next.js app, SQLite, and a small Node process. What makes it unusual is what it refuses to do. It never updates a row. Each restocker gets their own event store, a single append-only table, and every disc is an aggregate inside it. Commands don’t write state; they load the disc’s events, fold them into a current state, decide whether the request is legal, and append one more fact. Queries run the same fold and hand back the answer.
In this first version there are no cached read models at all. Every question is answered by replaying the ledger on the spot, which at boutique scale costs nothing and keeps the system honest.
The restocker’s whole job happens standing at the counter. Pick the mold from the live Shopify catalog, snap a photo; the photo itself is stored inside that first event as a blob, riding along with the fact it belongs to. By the time the disc is on the scale, the colour job has usually already been through: it shrinks the photo to 100×100, averages it down to a single RGB, and matches that against the colours this mold actually comes in. The restocker confirms the colour, types the weight, and taps create.
Here is what that last tap actually does:
The background processor is a few hundred lines of patience. Every five seconds it asks two questions, which discs need a colour estimate and which are ready for Shopify, and both questions are read models replayed on demand. For each ready disc it makes sure the weight string is unique for that mold (a second 168G RED PRISM Foil becomes 168G RED PRISM Foil 2), uploads the photo, creates the one-of-one variant with a quantity of exactly one, and reports back.
Reporting back is the interesting part. The processor never touches the ledger directly; success and failure go through the same command API as every human action. A Shopify failure isn’t a log line that scrolls away in a terminal. It’s ProductCreateFailed, attempt number included, sitting in the ledger where the next polling cycle will find it and try again. After five attempts it stops, and the dashboard says so.
When something looks wrong, nobody guesses. Every disc’s row on the dashboard is a fold over its own history, and the history reads like a story: began at 2:14, colour estimated at 2:14, colour corrected by a human at 2:15, weight set, ready, failed once on a rate limit, created on the second attempt. Debugging is reading.
The codebase also carries an honest scar. There’s a command handler whose entire body is a thrown error: “deprecated, use SetColorV2 instead.” The events the old command wrote are still in the ledgers, still replay, still count. That is what schema evolution looks like when your data is a log of facts rather than a table of current opinions: you don’t migrate the past, you just stop writing to it.
And the deliberately missing piece, cached read models, is a decision the architecture keeps open instead of forcing early. The day replay gets slow, projections slide in behind the same query endpoints without touching a single command. The write side never has to know.
The system is small, and that’s the brag. It’s small because the expensive part happened before the code: walking the workflow disc by disc, column by column, until everyone could point at the wall and say yes, that’s what happens. Nothing in production surprised anyone, because everything in production had already happened on paper.