Vertical slices in an admin rebuild
I rebuilt the admin of a restaurant client’s bookings app as two vertical slices, and the read slice shipped complete before any write action existed. Four read-only screens merged first. Eight actions merged 3 hours 41 minutes later and changed 4 lines of the read code. That order gave every action a screen to land on.
The read slice was four screens and no forms
The app started from a Shopify app template, so the admin it came with was a product demo. The first slice replaced it. It deleted 322 lines from the home route, removed a scaffold page, and dropped three API scopes that only the demo used. In their place went four screens: a list of today’s bookings, a rolling 7-day calendar, a booking detail page, and a search by name, email, or phone.
Every route in that slice answered GET and nothing else. The plan said so in one line: no mutation surface, forms are out of scope. One query module of 190 lines held four functions, and each route loader was a few lines of glue around one of them. The slice was 15 commits and took the suite from 139 tests to 162. None of the 23 new tests was a route test. A loader that authenticates, calls a tested query, and returns the rows has no decision in it to test.
One detail of the read slice mattered later. The booking detail page printed
the audit timeline: every event on the booking, with its timestamp and its
payload as plain JSON. At the time it showed two events, created and
confirmed.
The actions slice changed 4 lines of the read slice
The second slice added eight service functions in one module of 564 lines:
cancel, mark no-show, mark completed, manual confirm, edit, manual create,
and two resends. It added two routes, an edit form and a staff-only booking
form. The detail page gained one action export that reads a hidden intent
field and calls the matching service. Six intents go through that one
function.
The detail route grew by 206 lines and lost 4. The 4 lines were two import statements. The query module, the today list, the calendar, and the search did not change at all. The slice was also 15 commits, and it took the suite from 162 tests to 198.
Both slices merged on the same day, 14 May 2026. Each one went from a database query to a link in the navigation, which is what makes them vertical and not a backend layer followed by a frontend layer.
What shipping the read surface first bought
Every action had a place to land. All six intents end in the same redirect, back to the booking detail page. That page existed and worked, so the actions slice designed no result screens. An action was a button and a service.
The audit timeline verified the actions for free. An edit records a
modified event with a before and after of the changed fields. A cancel
records whether a refund was attempted and whether it succeeded. The detail
page already printed every event, so each action’s result was visible the
first time it ran, with no new interface. The modified event name had been
declared since the first slice of the app and had never fired. The read
surface showed it the first time it did.
The tests went where the risk was. The read slice has 384 lines of query tests and no route tests. The actions slice has 969 lines of service tests and the first route tests in the admin, because a route that dispatches on an intent has a decision in it. One mixed slice would have hidden that difference. Two slices made the test weight follow the consequence: a wrong list is a wrong list, a wrong cancel refunds a deposit.
The risky diff was only the risky diff. The read pull request could not move money or send an email, so it could be read as display code only. The actions pull request held the refund call, the email sends, and the state-transition guards, with no display code to read past.
The read surface could change without touching the writes. Nineteen days later I merged the today list and the 7-day calendar into one date-driven calendar. That change added 245 lines and removed 276 across four files. The actions module was not one of them.
What the ordering did not buy
Read-first did not make the read design right. The split between a today list and a 7-day calendar lasted 19 days. Shipping it first did not find that problem any sooner.
For 3 hours 41 minutes the admin could show a booking and could not change it. That cost nothing here, because the first production deploy was 33 days away. On a live system the same gap means staff look at a wrong booking with no way to correct it, and the gap has to be planned for, not assumed away.
Write-side problems stayed hidden until the write slice. A browser
datetime-local input returns a string with no timezone, so both new forms
had to convert it from the venue’s timezone before the schema would accept
it. The read slice did all its date maths in the venue timezone and gave no
warning of this. A read surface tells you nothing about input.
A read slice is only vertical if it is useful alone
The split worked because the read screens had a use without the actions: staff arriving for service need today’s list whether or not they can edit it. The same app shows the opposite case. Two days later the settings page shipped as one slice, read and write together, because a settings page that cannot save is not a feature.
So the rule I use now: split read from write when the write actions have consequences outside the database, such as refunds and emails, and the read screens are what you will use to check them. Keep them together when the read half does nothing for anyone alone. This is the same bookings system that runs SQLite in production, and the reasoning is the same kind: let the real consequence set the order of the work.
Revisions
- Created; body written from the bookings app's two implementation plans, its two pull requests, and the commit history from 2026-05-14 to 2026-06-16.
- Published one day after its Sep 30 slot; auto-drafted by the publishing run because no draft existed for the row.