A Worked Design, End to End
· Jerwin Arnado · 8 min read ·
The capstone of the series. Eleven chapters of patterns are worth nothing until you can assemble them on a real problem — so let’s design one, end to end, using the whole toolkit. The system: an appointment booking and reminder service for a clinic SaaS (the Lucas Clinic shape, simplified). Patients book slots; clinics manage schedules; everyone gets reminders. It’s a great capstone because it needs different answers on different features — which is the entire lesson of the book.
Watch the method as much as the answers: estimate, then decide each feature’s tradeoffs on its own terms.
Step 1 — Napkin math (always first)
Before any architecture, size it:
Clinics: 2,000
Bookings/clinic: ~40/day → 80,000 bookings/day → ~1 write/second
Availability views: ~100× bookings → ~100 reads/second (heavily read-skewed)
Reminders: 80,000/day, sent in time-based batches
Data: 80k × 365 × ~1KB ≈ 30 GB/year
The verdict, same as chapter one: this is a small system. One primary database handles it for years. No sharding, no distributed anything. What it does need is care on a few specific features — so let’s find them.
Step 2 — The CAP call, per feature
Sorting features by the cost of being wrong vs. down:
| Feature | Choice | Why |
|---|---|---|
| Booking a slot | CP (strong consistency) | Double-booking one slot is a real-world disaster. Correct-or-refuse. |
| Viewing availability | AP (eventual) | A calendar that’s a few seconds stale harms no one; it must always load. |
| Reminders sent | AP + idempotent | Send is async; the guarantee we need is “not twice,” not “instantly.” |
One system, three different consistency answers. That table is the architecture in miniature — everything below implements it.
Step 3 — The booking write (the sharp end)
Booking is the one operation that must be correct. Two patients must never win the same slot. This is a strong-consistency write against the primary, and the enforcement lives in the database, not the app:
public function book(BookingRequest $request)
{
$key = $request->header('Idempotency-Key'); // client-generated per tap — retry-safe
return DB::transaction(function () use ($request, $key) {
// Idempotency: a retried booking returns the original, never books twice
if ($existing = Booking::where('idempotency_key', $key)->first()) {
return new BookingResource($existing);
}
// The slot's uniqueness is enforced by the DB, not by an app-level "is it free?" check.
// A unique index on (slot_id) makes a double-book a caught constraint violation,
// not a race two requests can both win.
return Booking::create([
'slot_id' => $request->slot_id,
'patient_id' => $request->user()->id,
'idempotency_key' => $key,
]);
}); // unique(slot_id) violation → 409 "slot just taken", caught and returned cleanly
}
Every earlier chapter shows up in this one method:
- Idempotency: the patient on flaky mobile signal taps “Book” twice, or the client retries a timed-out request — the key guarantees one booking, not two.
- Strong consistency: the write and the read that guards it are on the primary, in one transaction. No replica lag can let two requests both see the slot as free.
- The database enforces the invariant:
unique(slot_id)means correctness doesn’t depend on getting application logic perfectly race-free — the constraint is the backstop.
Step 4 — Viewing availability (the common case)
Availability is read ~100× more than it’s written and tolerates slight staleness — textbook cache-aside served from a read replica:
public function availability($clinicId, $date)
{
// Cache-aside with TTL jitter so a popular clinic's key doesn't stampede on expiry
return Cache::remember(
"avail:{$clinicId}:{$date}",
now()->addSeconds(30 + rand(0, 15)), // jittered TTL — no synchronized expiry
fn () => Slot::on('replica') // reads offloaded to a follower
->where('clinic_id', $clinicId)
->whereDate('starts_at', $date)
->where('is_booked', false)
->get()
);
}
And when a booking succeeds, invalidate that clinic/date key so the freed/taken slot shows up fast — TTL as the backstop, explicit invalidation for freshness. The 30-second staleness is the deliberate AP bet from the table: a patient might see a slot that was taken two seconds ago, try to book it, and get a clean “just taken” from Step 3’s unique constraint. The strong-consistency write backstops the eventually-consistent read — that’s the two working together, exactly as designed.
Step 5 — Reminders (async, idempotent, resilient)
Reminders are pure asynchronous work — nobody’s waiting on a screen — so they run on a queue, and they stack every failure pattern from the book:
class SendReminder implements ShouldQueue
{
public $tries = 3;
public function backoff(): array { return [60, 300, 900]; } // backoff + jitter
public function handle(): void
{
// Idempotent: at-least-once delivery means this may run twice — guard on a marker
if ($this->booking->reminder_sent_at !== null) {
return;
}
// Timeout + circuit breaker around the flaky SMS gateway; degrade to email if it's down
$this->smsGateway->send($this->booking->phone, $this->message());
$this->booking->update(['reminder_sent_at' => now()]);
}
}
- Async: sending 80,000 reminders never touches a user request; the queue smooths the batch across workers.
- Idempotent:
reminder_sent_atguarantees a patient never gets the same reminder twice, even when a worker crashes after sending but before acknowledging. - Failure design: timeout + retries with backoff handle a flaky SMS provider; a circuit breaker stops hammering it when it’s truly down; falling back to email is graceful degradation.
Step 6 — The patient’s experience (the user’s clock)
Finally, make it feel instant with chapter ten:
- Prefetch: when a patient hovers a clinic in the list, prefetch its availability, so the calendar is already there on click.
- Optimistic UI — carefully: for booking, we do not go optimistic. A booking can fail (the slot was just taken), and the patient must know the true outcome — so it’s a synchronous wait with a clear result. But cancelling a booking, which essentially always succeeds, can be optimistic: remove it from the list instantly, reconcile if the rare failure occurs. The same screen, two different bets, split along “must the user know the outcome before moving on?”
Putting it all together
Napkin math → small system, one primary DB, no sharding (the most important finding)
Booking → CP: primary + transaction + unique(slot_id) + idempotency key
Availability → AP: cache-aside on a replica, jittered TTL, invalidate on booking
Reminders → async queue, idempotent (reminder_sent_at), backoff + breaker + degrade
Experience → prefetch availability; optimistic cancel, synchronous book
Every chapter of the series lands somewhere in that one diagram — and notice what the design didn’t do: no microservices, no message bus, no distributed database, no Kubernetes. The napkin math said “small,” so the design stayed small, spending complexity only on the two or three features that genuinely earned it — correct bookings, resilient reminders, and a UI that feels instant.
That’s the whole method, and the whole point of the book: estimate honestly, decide each feature’s tradeoffs on its own terms, and reach for the heavy patterns only where a real requirement forces your hand. System design isn’t a stack of technologies you adopt — it’s the judgment to know which knob to turn, and the discipline to leave the rest alone.
Thanks for reading the series. If you want the practical wiring under any of these patterns, the thirteen-layer stack tour is its hands-on companion — the how to this book’s why and when.