A car dealership: vehicle stock, preparation processes, sales, service, used-car listings. All of it running on spreadsheets and messaging apps.
The ask was not “let’s install a CRM”. It was turning the dealership’s way of working into software.
By the end of the first day there were 29 commits and none of them was half-finished.
Why I did not start at the screen
Starting at the screen feels fast. Three days later you have a working screen with nothing underneath it. On the fourth day the first real rule arrives — “this car is in service but can it also be listed for sale?” — and you rewrite both the screen and the database.
Starting at the bottom feels slow and is not. Stack each layer on the one below and prove it works at that point, and you never go backwards.
I did not revert once all day.
Nine layers
| # | layer | what it proves |
|---|---|---|
| 01 | Schema — 16 migrations | the data model is consistent and tenant-scoped |
| 02 | Value objects | VIN, plate, money and dates carry their own rules |
| 03 | Entities + events | a vehicle is an identity, not a row |
| 04 | State transition graph | “can it go from here to there?” lives in one place |
| 05 | Repository ports | the domain does not know the database |
| 06 | Repository implementations | something has to know it, and this is where |
| 07 | Application service | use cases plus event dispatch |
| 08 | Admin interface | visible to the eye for the first time |
| 09 | Event handlers | the timeline projection |
The important property of this list is not its order but that every row is testable on its own. When layer 4 was done I called the transition graph and got the right answer; there was no screen and no HTTP.
The core beneath the core
Before layer 1, an application shell had to boot. That fitted into the same day too:
Container and routing. Multi-tenancy — repositories scope queries to the tenant automatically. Localisation. An event backbone. A view engine. Validation. File storage (traversal-safe). Settings and feature flags. An append-only audit log. Authentication, an authorisation gate, CSRF. A queue provider.
In a multi-tenant system, never leave the filter to whoever writes the query. The data layer adds the tenant condition itself. A developer forgetting WHERE tenant_id becomes physically impossible.
This is the most expensive class of bug in any SaaS: one tenant seeing another’s data. And it always happens because it was forgotten, never because it was unknown.
The dullest commit of the day
In the middle of those nine layers sits this: target the common subset of MariaDB 10.4 and MySQL 8.
Dull. But the server is MariaDB and the development machine is MySQL. Their default character sets and collations are not the same.
Finding that after writing 16 migrations would have meant rewriting all of them — and the bug would have surfaced in production as two differently spelled plates comparing equal.
Permissions belong to data, not code
In the dealership a car goes through a “wash” process. The washer must mark it done — and must not see the sales screens.
Solve that with roles and you write a new role for every new process. Instead the permission code is derived from the process type:
// a new process type is defined: "ceramic coating"
process.code = 'ceramic'
// the permission code exists on its own; nobody writes code
→ vehicle.process.ceramic
And the quality bar
The add-vehicle screen is not a stack of dropdowns but a step-by-step wizard. Large touch targets at each step, and a live vehicle profile assembling on the right as you choose.
The reason: nobody in a dealership sits at a keyboard. Everyone is standing next to a car with a tablet.
Making that decision on day one mattered, because it shapes the data model too: “touch-first” means every option needs an image and a short label. Retrofitting it would have meant changing every catalogue table.
What I learned
The reason for 29 commits is not that I type fast. It is that I never went backwards. And the reason for that is running each layer for real before moving to the next.
“Logic first, code second” is not a slogan; it is a scheduling decision. Try to discover the logic inside the code and every discovery forces you to delete what came before.