← All posts

Modeling a fleet: keeping dozens of apps honest with executable specs

13 June 2026 · engineering architecture building-in-public

The failure mode of building a lot of small apps is entropy. Each one starts coherent. Then the docs fall behind, a few tests get skipped, the privacy policy describes a version that shipped six months ago, and nobody can tell you with confidence what any given app actually does without reading all of it.

Across our apps we fight that with a single idea: a model of what each app does, written as code, checked against the real code on every build, and used to generate the things that otherwise drift. This is a developer-level look at how it works.

The model is Event Modeling, in files

The backbone is Event Modeling, Adam Dymitruk's design language. We didn't invent it; we made it executable. It has four building blocks:

The unit is a vertical slice: one trigger, one command, the events it can produce on success or failure, a Given/When/Then spec with example data, and a pointer to the code that implements it. Each slice is a TypeScript file under model/, with a stable id and a status that moves from draft to implemented. The landing site alone is 38 slices.

The point of the slice is that it's the smallest thing you can hand to a developer and have them implement without asking questions, because everything they need is in it: the input, the outcomes, the example, and where it lives in the tree.

Checks that fail the build

This only matters because the model isn't allowed to be aspirational. An umbrella test runs on every build and does four things.

It checks graph integrity: every event has to be reachable from a command, every read model has to trace back to events. A dangling event or an orphan read model is a failure, not a warning.

It runs a set of flow heuristics over the model looking for the shapes of UX problems: a screen with no way out, an action with no feedback event, that sort of thing.

It checks privacy gaps (the next section).

And it checks telemetry drift. Each slice's events are compared against the analytics events the code actually fires. If the code emits an event the model doesn't declare, or the model declares one the code never fires, the build fails. We hit this ourselves: we added an analytics call for a signup before we'd modeled it, and the build refused to pass until the model and the code agreed. Drift stops being something you notice in six months and becomes a compile error today.

The load-bearing piece is traceability: each slice names the code files that implement it, so "the model" and "the code" aren't two things hoping to stay in sync. They're checked.

The privacy policy generates itself

This is the payoff we're fondest of. Alongside the behaviour, we declare the kinds of data an app handles. Each category records the facts a privacy policy needs anyway: where it's stored, how long, the legal basis, who it's shared with, whether you can delete it. Any data a feature touches has to point at a declared category.

The gap check fails the build if an app collects something no category covers. So you can't merge a feature that quietly collects an email the policy doesn't mention. And because the categories carry the real facts, the public privacy page is generated from them with one command, not hand-written.

Concretely: when we added the newsletter signup, the build wouldn't pass until we declared a "newsletter email" category (consent, kept until you unsubscribe, removable). The privacy page then regenerated to include it without anyone touching the page. Forgetting to disclose it wasn't an option the build offered.

What it buys, and what it costs

The same model emits more than the privacy page. The sitemap is generated from the screens marked public, so it can't list a page that doesn't exist or miss one that does. Estimation gets easier because slices are roughly uniform in size by construction. Onboarding a feature is reading one slice, not the whole app.

The cost is real and worth being honest about. Writing the model before the feature is slower than not writing it, full stop. And it describes what an app is designed to do, not a recording of what it did, so a slice can be correct while a bug does something it shouldn't. This is a strong default, not a proof.

For a small studio keeping dozens of small apps from rotting, that trade is easy. The speed we lose is small and what we get is a fleet where the docs, the tests, the telemetry, and the privacy policy are all checked against the same source instead of drifting away from it on their own schedules. The gates that say "no" are the most valuable code we've written.

New posts and projects, by email. Double opt-in, unsubscribe anytime.