Lesson 4 of 4
A Complete Worked Decision
Every technique in the chapter applied end to end to one real problem: choosing how a booking platform stores and queries availability.
Techniques taught separately are easy to agree with and hard to apply. This lesson runs one decision from a vague brief through to a recorded rationale, using the reversibility test, quality attribute scenarios, trade-off analysis and a fitness function in sequence. The domain is deliberately mundane — the reasoning is the subject, not the technology.
Step 1 — Restate the decision without a technology in it
How should the booking platform store and query resource availability for the next three years, given a team of six, launch in five months, and a hard requirement that a resource is never double-booked?
Step 2 — Force the numbers out
| Attribute | Stated as | Agreed measure |
|---|---|---|
| Correctness | "No double bookings" | Zero double-bookings; a conflicting request must fail, not queue |
| Search latency | "Fast" | Availability search under 400ms at p95, 200 concurrent users |
| Write volume | "Lots of bookings" | ~3,000 bookings/day at launch; 30,000/day is the 3-year optimistic case |
| Modifiability | Not mentioned | A new resource type ships in under a week |
| Reporting | Not mentioned | Finance needs nightly cross-resource utilisation reports |
Thirty thousand bookings a day is roughly 0.35 writes per second averaged, perhaps twenty at a realistic peak. This is the moment the decision effectively resolves: that volume is unremarkable for any mainstream database, so throughput — the thing the original brief emphasised — is not a differentiator at all. The forces that actually differentiate are the correctness invariant and the reporting requirement nobody mentioned.
Step 3 — Write the decisive scenario
Source: Two users of the booking application
Stimulus: Both request the same resource for the same time slot,
within 50ms of each other
Artefact: The booking service and its datastore
Environment: Normal operation, 200 concurrent users, both requests
handled by different application instances
Response: Exactly one booking is created; the other request is
rejected with a conflict the user can act on
Response measure: 0 double-bookings across 10,000 concurrent-conflict
attempts in the load testStep 4 — Compare the surviving options
Three options, honestly costed
PostgreSQL with an exclusion constraint
Availability as time ranges with a database-enforced constraint preventing overlapping bookings on the same resource.
- The invariant is enforced by the database and cannot be bypassed by any code path
- Transactions make the booking flow trivially correct
- Reporting is SQL against the same store
- The team already runs PostgreSQL in production
- Range types and exclusion constraints are unfamiliar to most of the team
- Horizontal write scaling would need work — irrelevant at this volume, real at 100x
Choose when: Correctness is a hard invariant, volume is moderate, and cross-entity reporting matters. Which is this case, and most cases.
MongoDB with application-level checking
Documents per resource, with the booking service checking for conflicts before writing.
- Flexible schema for varying resource types
- Familiar to two engineers on the team
- The invariant cannot be enforced across instances without a separate lock service
- Cross-resource reporting needs an aggregation pipeline or a second store
- The flexibility being paid for addresses a problem the team does not have
Choose when: The document model matches the access pattern and there is no cross-document invariant to enforce — genuinely not this system.
PostgreSQL plus Redis for availability lookups
Authoritative data in PostgreSQL, with a Redis cache serving availability searches.
- Very fast reads
- Keeps the correctness guarantee in PostgreSQL
- Cache invalidation on every booking, with a stale-read window that shows users unavailable slots
- A second stateful service to operate, for a team of six with no dedicated operations
- Solves a latency problem that has not been measured yet
Choose when: The measured p95 misses the target with PostgreSQL alone. Adding it before measuring is gold-plating.
Step 5 — Decide, and make it enforceable
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE bookings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
resource_id uuid NOT NULL REFERENCES resources(id),
period tstzrange NOT NULL,
status booking_status NOT NULL DEFAULT 'CONFIRMED',
created_at timestamptz NOT NULL DEFAULT now(),
-- The whole correctness requirement, in four lines. Two concurrent
-- transactions cannot both commit an overlapping period for one resource:
-- the second fails with a constraint violation the service maps to 409.
-- No application code path can bypass this, including a future one written
-- by someone who has never read the ADR.
CONSTRAINT no_overlapping_bookings
EXCLUDE USING gist (
resource_id WITH =,
period WITH &&
) WHERE (status = 'CONFIRMED')
);
CREATE INDEX bookings_resource_period_idx
ON bookings USING gist (resource_id, period);The fitness functions that keep it true
- 1
Concurrency test in CI
10,000 concurrent conflicting booking attempts against a real PostgreSQL container; assert exactly one success per slot. This is the response measure from the scenario, executed. It failed twice in the first year, both times catching a code path that had started using a weaker isolation level.
- 2
Latency check against the p95 target
A load test at 200 concurrent users asserting availability search stays under 400ms. This is what would trigger revisiting the Redis option — with evidence rather than speculation.
- 3
A named revisit trigger in the ADR
"Revisit if sustained write volume exceeds 500/second, or if the p95 latency check fails for two consecutive weeks." The decision is now falsifiable, and the argument in eighteen months will be about a measurement rather than about preferences.
In practice
What the decision looked like eighteen months later
The platform launched on PostgreSQL with the exclusion constraint. Volume reached 11,000 bookings a day, well inside projections. Two things happened that nobody had predicted.
Constraints
- A new resource type with recurring weekly availability
- Finance asked for near-real-time utilisation, not nightly
- Still six engineers
- Zero double-bookings recorded
Decision
Both new requirements were absorbed without an architectural change. Recurring availability became additional rows with the same constraint; near-real-time reporting became a read replica.
Why
Neither requirement was anticipated, and neither needed to be. The decision had been made on the invariant and the access pattern rather than on a predicted feature set, and both new requirements were still bookings and still queries.
What it cost
The Redis layer was never built, so availability search sat between 180ms and 310ms rather than the 40ms a cache would have given. Nobody complained, because 400ms was the actual requirement. The team spent that engineering time on the recurring-availability feature instead. The MongoDB proposal that opened the brief would have required a distributed lock service by month three, when the first concurrent-booking incident would have occurred.
What was the single most valuable step in this decision, and why?RevealHide
Forcing the numbers out in step two. It revealed that write throughput — the thing the brief emphasised and the reason MongoDB had been proposed — was not a differentiator at all, and it surfaced the reporting requirement that nobody had mentioned. Everything after that was mechanical. Most architectural decisions that go badly go badly because the analysis was rigorous about the wrong forces, and the only defence is to establish which forces are real before comparing anything.
Key takeaways
- Restate the decision without a technology in it; a named product in the question has already decided it.
- Forcing vague requirements into numbers usually eliminates most options before any comparison starts.
- A well-written scenario can decide the architecture on its own — here, two racing instances ruled out application-level enforcement.
- Push invariants to the lowest level that can enforce them, so no future code path can bypass them.
- Record the trigger that would make the decision wrong, so the next argument is about a measurement.