8Examples / work
Case study · DiscRepublic.ca

A store where nothing comes in twos.

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.

Product detail screen: the disc centered on a light blue canvas, the color estimated from the photo, and the weight ready for submission to Shopify
The product detail screen. One disc, photographed and weighed; a background job has already centered it on the light-blue canvas and estimated its colour from the photo, ready to submit to Shopify.
The Shop

Players don’t buy a Destroyer. They buy that one.

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 First Move

We modeled the timeline before we touched a keyboard.

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:

SCREENS &AUTOMATIONCOMMANDSEVENTS(the ledger)READ MODELStime01 · at the counterpick the mold,snap a photoBeginCreateProductBeginProductCreatedthe photo rides insideauto · colour jobwakes every 5 sSetEstimatedColorColorEstimatedone averaged RGBProductsNeedingColorEstimation02 · review colournearest real colour,already pickedSetColorV2ColorSetV2the human wins03 · weigh it“168G RED PRISM Foil”FinishCreateProductProductWeightSetProductReadyToBeCreatedauto · shopify jobuploads the photo,creates the variantRecordProductCreatedInShopifyProductCreatedProductCreateFailedretried next cycle · five strikesProductsToCreateInShopify04 · dashboardstatus: createdProductStatea fold over history
screenautomationcommandeventread model
Read it left to right; that axis is time. Gold notes are events: facts, past tense, never edited. Blue notes are commands: requests that can be refused. Green notes are read models: questions the ledger can answer. Everything in the top lane is a person or a robot doing something.

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 Shape

An append-only ledger and two patient robots.

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.

7
event types
2
background jobs
5s
polling loop
≤5
retries, then stop
0
UPDATE statements
On the Floor

At the counter, with a scale and a phone.

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:

restocker’s phonecommand handler(next.js route)events.db(append-only, one per restocker)POST /api/commands/finish-create-product{ weight: “168G RED PRISM Foil” }SELECT events WHERE aggregate_id = …four events, oldest firstreplay → { status: “data-entry”, … }guard: is this command legal here?INSERT ProductWeightSet · version 5INSERT ProductReadyToBeCreated · version 6ok200 · the disc is now “creating”
No products table exists to update. The handler reads history, folds it, checks the guard, and appends. Even the version number is just the length of the story so far.
Meanwhile

The robot clerk works the night shift too.

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.

background processorquery · command api(next.js)events.dbshopify admin apiloop · every 5 sGET products-to-create-in-shopifyx-api-key · the robot has no loginload + fold every open ledgerthe ones sitting at “creating”[ { aggregateId, weight, colour, … } ]GET product-imagethe photo blob stored inside event #1jpeganyone already at “168G RED PRISM Foil”? then append “2”upload photo · create 1-of-1 variant · quantity: 1variant id, or an erroralt[created]POST record-product-created-in-shopifyappend ProductCreated[shopify said no]POST record-product-failed-in-shopify · attempt nappend ProductCreateFailedthe failure is a fact in the ledger · seen again next cycle · abandoned after five
The loop runs forever; the alt frame is where honesty lives. Both outcomes are appended as events through the same front door the restocker uses.
The Payoff

What an immutable ledger actually buys.

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 Point

The model was the design. The code is the model, typed in.

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.