# Vertical slices in an admin rebuild

Ship the read surface first. For a restaurant bookings app I built the admin as two vertical slices on one day: four read-only screens with 23 new tests, then eight write actions with 36 more, merged 3 hours 41 minutes later. The actions slice changed 4 lines of the read code. The order gave every action a screen to land on and an audit timeline that showed its result.

Published: 2026-10-01
Canonical: https://umar.codes/vertical-slices-admin-rebuild

## Revisions

- 2026-10-01 — 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.
- 2026-10-01 — Published one day after its Sep 30 slot; auto-drafted by the publishing run because no draft existed for the row.

---

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](/sqlite-in-production), and the reasoning
is the same kind: let the real consequence set the order of the work.
