An example system architecture document
Example
Lidoline is a made-up booking platform for lidos and outdoor pools. This is the document we’d hand over before anyone writes code: the system, the decisions, the options we ruled out, the cost picture and how it fails.
Example. Lidoline is fictional. Names, dates, loads and costs are invented to show the shape of the work. The costs are the made-up client’s own hosting bills, not our fees.
What it’s for
A system architecture document says what a system is made of, why each part was chosen, and what happens when parts of it fail, before anyone builds it. A good one covers the services and their boundaries, the data model, hosting and cost at today’s load and the planned one, observability, failure modes, and how the system grows, along with the options that were ruled out.
Below is a complete example for Lidoline, a fictional booking platform, written the way we’d deliver it.
Context and requirements
- Lidoline: system architecture
- 1.0, final
- 16 October 2026
- Lidoline Ltd (Jess Okafor, founder; Sam Reyes, product)
Status: Reviewed with Jess and Sam on 14 October. Changes from that review are listed under section 11.
1Context
Lidoline sells session booking to lidos and outdoor pools. Each venue is a customer (a “tenant”): some are council-run, most are community trusts. Swimmers book a session, pay, and show a QR code at the gate. Venue staff manage sessions, capacity, memberships and closures.
Today Lidoline runs on a no-code booking tool with a custom front end. It works for the four pilot venues. It will not work for the 40 venues signed for the 2027 season, for three reasons:
- The hot-morning spike. Venues release the next seven days of sessions at 7am. On the first hot day of 2026 one pilot venue had 1,100 people trying to book 240 places in the first five minutes. The current tool double-sold 31 places.
- Closures. When a venue closes for weather or water quality, every booking for the session has to be cancelled, refunded and told, quickly. Today staff do this by hand.
- Money. Each venue must be paid directly. Lidoline takes a booking fee and must never hold venue money itself.
2Requirements
Must
- Never sell more places than a session holds, under any load.
- Take payment on behalf of each venue, with Lidoline’s fee split out at the time of payment.
- Cancel, refund and notify every booking in a closed session within 15 minutes of staff pressing “close”.
- Let gate staff check people in when the venue’s Wi-Fi drops, which it does.
- Keep each venue’s data separate from every other venue’s.
Should
- Handle the 7am release for 40 venues at once, at five times the worst pilot spike.
- Be run and changed by a small team. Lidoline plans to hire two developers in 2027.
Won’t (this version)
- Lessons and courses. Planned for 2028.
- A native app. The booking site works on phones and saves the QR code to the wallet.
The system
3The system at a glance
Four tiers, left to right: who uses it, the applications, the data, and the infrastructure and outside services. The booking hold (Redis) is the part that carries the hot-morning problem.
4Services and boundaries
One booking API, not several services. Bookings, sessions, memberships, closures and payments live in one TypeScript service with clear modules inside it. The modules talk through function calls and their own tables, never each other’s tables, so any one of them can be split out later if it needs to be.
| Part | Does | Doesn’t |
|---|---|---|
| Booking site (Next.js) | Public booking pages, venue dashboard, account pages. Renders session lists on the server so they’re fast on phones. | Hold any business rules. Every rule lives in the API. |
| Booking API | Sessions, capacity, holds, bookings, memberships, closures, Stripe payment set-up, webhooks from Stripe. | Send messages or refunds itself. It puts a job on the queue. |
| Job worker | Refunds, emails, texts, the 7am release, nightly reports to venues. Retries each job with backoff. | Take requests from the outside world. |
| Gate check-in | A web app installed on staff tablets. Downloads the day’s bookings each morning, checks QR codes locally, syncs check-ins when it can. | Need a connection to let someone in. |
5Data model and storage
PostgreSQL holds everything that matters: tenants, venues, sessions, bookings, payments, memberships, check-ins. Every table that holds venue data carries a tenant_id, and row-level security in Postgres enforces it, so a bug in one query can’t show one venue another venue’s swimmers.
| Table | Key fields | Notes |
|---|---|---|
| tenants | id, name, stripe_account_id | One per venue operator |
| venues | id, tenant_id, name, timezone | A trust may run more than one pool |
| sessions | id, venue_id, starts_at, capacity, status | Status: open, full, closed, cancelled |
| bookings | id, session_id, swimmer_id, places, status, payment_intent_id | Unique on payment_intent_id, so a retried webhook can’t book twice |
| holds | session_id, hold_id, places, expires_at | In Redis, not Postgres. See below |
| check_ins | booking_id, device_id, checked_in_at | Written by the gate app on sync; duplicates ignored |
Redis holds places while a swimmer pays. Taking a hold is one atomic operation: decrement the session’s available count if it’s high enough, otherwise refuse. Holds expire after eight minutes. Postgres is the record; Redis is the turnstile. If Redis and Postgres ever disagree, Postgres wins and a nightly job reconciles the counts.
Why not DynamoDB: see decision D2.
Running it
6Hosting and cost
Booking site on Vercel. API, worker, Postgres (RDS), Redis (ElastiCache) and the queue (SQS) in one AWS account owned by Lidoline, in eu-west-2 (London). Estimated monthly running costs, before Stripe’s per-transaction fees:
| Item | 2027 season (40 venues) | At 10 times that |
|---|---|---|
| Vercel (booking site) | Under £100 | £100 to £300 |
| API and worker (ECS Fargate, two small tasks each, more at 7am) | £50 to £150 | £300 to £600 |
| PostgreSQL (RDS, Multi-AZ) | £150 to £250 | £400 to £800 |
| Redis (ElastiCache, small) | Under £50 | £50 to £150 |
| Queue, email and SMS | Mostly SMS: depends on how many venues turn it on | Same |
| Observability | Under £50 | £100 to £300 |
The biggest cost lever is SMS, not hosting. Closure messages go by email by default and by text only where the venue pays for it.
7Observability
- Traces on every API request and job, with tenant_id and session_id attached, sent through OpenTelemetry so the vendor can change without touching code.
- Metrics that matter to the business, not just the servers: holds taken per minute, holds expired, bookings per minute, refund jobs waiting, minutes since a closure was pressed.
- Alerts on: any oversell (should be impossible, so it pages), refund jobs waiting more than 10 minutes, Stripe webhook failures, error rate above 2% for five minutes.
- Logs structured as JSON, kept for 30 days, with no card or health data in them.
8Failure modes
| What fails | What swimmers see | What the system does |
|---|---|---|
| 7am spike beyond the plan | A short queue page (“you’re in line”), then booking as normal | The site admits people to booking at the rate the API can serve; holds stop overselling regardless of load |
| Stripe is down | “Payments are paused, your place is held for eight minutes” | Holds expire normally; nothing is booked without a confirmed payment |
| Stripe webhook arrives twice, or late | Nothing | Bookings are unique on the payment ID; late webhooks still confirm if the hold has lapsed but the place is free, otherwise refund automatically |
| Redis is lost | Booking pauses for a minute or two | API falls back to a Postgres row lock per session: slower, still correct. Redis counts rebuild from Postgres on restart |
| A venue closes a session | Email (and text if enabled) within 15 minutes, refund to the card | One job per booking on the queue; the worker retries failures; staff see a live count of refunds done and waiting |
| Gate Wi-Fi drops | Nothing | Gate app checks QR codes against its morning download; syncs later; duplicate check-ins are ignored |
| Email or SMS provider is down | Message arrives late | Jobs retry for six hours, then alert |
| Whole AWS region is down | Booking site shows a status page | Accepted risk for v1. Nightly database snapshots copied to a second region; restore runbook tested before the season |
9How it grows
- First to strain: the 7am release. At 40 venues on one hot morning, the plan is five times the worst pilot spike. The holds design handles that; the queue page is the safety valve beyond it.
- At ten times the venues: Postgres moves up one instance size, the API scales out (it’s stateless), and the 7am release staggers by a few minutes per venue, which venues won’t notice and the system will.
- What can wait: read replicas, splitting out the payments module, a second region. None of them is needed for 2027, and each has a clear trigger written down in section 11.
Decisions and build order
10What to build first
- Sessions, holds and bookings with Stripe Connect, end to end, for one venue. Load test the hold against 5,000 simultaneous attempts on 240 places before anything else is built.
- Closures, refunds and messages.
- The venue dashboard.
- Gate check-in.
- Memberships.
- Migration of the four pilot venues, then onboarding the rest in batches of ten.
11Decisions and options ruled out
D1One service, not microservices
- One booking API with strict modules inside it.
- Separate services for bookings, payments, memberships and messages.
- A team of one to three developers can’t run six services well. One service with strict modules gives the same boundaries without the network between them.
- One module needs to deploy on its own schedule, or more than six people work on the API.
D2PostgreSQL, not DynamoDB
- PostgreSQL on RDS as the record for everything.
- DynamoDB.
- Bookings, payments and refunds need transactions across several records, and venues will want reports Lidoline hasn’t thought of yet. Postgres does both. DynamoDB would handle the 7am spike well but makes the rest harder.
- Never, for this product, unless write volume grows a hundredfold.
D3Redis holds, not database locks alone
- Places held in Redis with one atomic operation, with Postgres row locks kept as the fallback (section 8).
- Locking session rows in Postgres on its own.
- Row locks are correct but queue every swimmer behind one row at 7am. Redis takes the hold in microseconds.
D4Stripe Connect, not collecting and paying out
- Stripe Connect: venues are paid directly and Lidoline’s fee is split at the moment of payment.
- Collecting into one Lidoline account and paying venues weekly.
- Lidoline never holds venue money. Collecting and paying out brings safeguarding obligations Lidoline doesn’t want.
D5A web app for gate check-in, not a native app
- A web app installed on the venue’s own tablets, working offline once loaded.
- A native tablet app.
- It installs on any tablet the venue already has, with no app store release to manage.
- Venues want contactless card scanning at the gate, which needs native access.
Deferred, and what brings each back
- Read replicas: when database CPU stays above 60% through the 7am release, or venue reports start to slow bookings down.
- Splitting out the payments module: the trigger in decision D1.
- A second region: when a venue contract requires booking to keep running through a regional outage, which the snapshot restore in section 8 can’t do.
Changed after the 14 October review
- Closure messages default to email, with SMS optional per venue (Jess, on cost).
- Added the queue page for the 7am release (Sam, from the 2026 pilot complaints).
12Open questions
- Will councils require their venues’ data to stay in a UK region? Assumed yes; eu-west-2 covers it.
- Do any venues need to take cash at the gate? If so, the gate app needs a “walk-in” booking, which isn’t designed yet.
Have something to build?
Tell us what you're working on. We'll tell you how we'd approach it.
We reply within one working day.