Learn System Design with a booking system: requirements, data, capacity, failures and trade-offs, without adding complexity before you need it.


A scheduling screen looks simple: show the free slots, let the user pick one and save the booking.
Now two people pick the same slot at the same time. Both get a confirmation. In another attempt, the booking is saved, but the response gets lost on the network. The user tries again. Meanwhile, the email provider slows down and the screen keeps loading.
It's common to associate System Design with giant companies, big tech interviews or systems with millions of users. That makes sense: those are the places where the topic shows up the most.
But this fictional scenario needs none of that to go wrong. Concurrency, a communication failure and decisions that were left implicit are enough. A clinic with three practitioners already has all of it: two patients picking the same slot, a response lost on the network and nobody having decided what happens when the email is late.
Organizing controllers, services and repositories helps you build the code. But we still need to decide who confirms the booking, which data needs protecting and what keeps working when a dependency fails.
That's where System Design comes in.
Let's follow this booking system from the problem to the first design, without starting from a list of technologies.
TL;DR
System Design is designing how components, data and communication work together to meet real requirements.
Start with the expected behavior, the limits and what must not go wrong. Then draw an initial solution, test it against load and failures, and add complexity when there's a concrete reason to.
System Design is the activity of defining responsibilities, interfaces, data and interactions so that a system delivers the expected behavior and quality. The design needs to account for both the features and the requirements for performance, security, availability and evolution.
It isn't just choosing where to put a cache. It's understanding what work that cache would save, how long the data could be stale and what would happen if it went down.
A practical distinction: System Design is the work of connecting need and solution. Software architecture is the structural design and the set of decisions that this work helps produce and review. The terms overlap; the distinction is there to guide the conversation, not to draw a rigid line.
The first question isn't which technology to use. It's what commitment the system needs to keep.
Requirements define what the solution must deliver; constraints limit the paths available. Without both, comparing architectures turns into a contest of preferences. Both the design method from the and Microsoft's guidance start from this information to guide decisions.
For our system, let's assume fixed 30-minute slots, registered in advance, with one opening per practitioner in each slot. Users look up and cancel their appointments; managers follow the schedules of the practitioners on the teams they're responsible for.
Payments, recurrence and integrations with external calendars are out of scope for this first version.
Each kind of requirement asks something different of the design. For scheduling, the first definitions would be these. The numbers are teaching assumptions, not universal recommendations:
| Kind | Question | In the booking system |
|---|---|---|
| Features | What needs to happen? | Check availability, book, cancel and follow authorized schedules. |
| Quality | Under which conditions must it work? | Answer 95% of queries within 300 ms at the API, under the peak target load. |
| Constraints | Which limits narrow the choices? | Four developers, eight weeks and infrastructure the company already runs. |
Each row already shapes the design:
An invariant is a property the system must preserve throughout its operations. In our case:
A slot cannot have two active bookings.
This rule holds when two people click at the same time, when a request is repeated and when there are several instances, or running copies, of the application.
Another rule in the example is that being a manager doesn't grant access to every schedule. Access depends on which team that person is allowed to follow.
Before designing the solution, we've already found two responsibilities: protecting the slot's occupancy and checking the scope of access. They can't depend on the screen alone.
A capacity estimate is there to guide choices, not to predict the future precisely. What matters first is the order of magnitude, separating normal traffic, peaks and growth.
Suppose 10,000 daily active users, each running twenty availability queries:
That doesn't mean three queries per second is enough. Demand doesn't spread across the day: it piles up when the schedule opens. If a thousand of those people arrive within the same five minutes, the same math changes scale:
With room for what the estimate can't see, let's set a test target load of 100 queries and 20 booking attempts per second. The 20 attempts cover one request from each of those thousand people in the first minute. It's a peak assumption to validate, not a measurement.
Growth calls for a different calculation: storage. If we have five thousand new bookings per day, at 2 KB of logical data each, that's about 10 MB per day or 3.65 GB per year, using decimal units. Extra history, indexes, replicas and backups haven't been added yet, and the article on storage estimation shows how to add them up.
Even multiplied a few times, that volume fits comfortably in a single relational database. The math doesn't size the database on its own, but it keeps “we need a distributed database” from showing up without a justification.
Where the demand concentrates matters too. The 20 attempts per second could be for twenty different practitioners or all for the same slot: those are different tests, and the request total doesn't tell that part of the story.
The first design should show responsibilities and data paths, not every infrastructure detail. The model organizes these views by zoom level; here, a small logical view is enough to discuss the main choices.
With four developers and eight weeks, I'd start with a modular application and a relational database. Scheduling, Teams and Authorization would be modules with separate responsibilities, not three microservices.
A would handle notifications. The booking and the pending notification would be persisted in the same transaction; sending would happen later.
The design separates what the user has to wait for from the work that can continue in the background:
This proposal avoids adding network communication between parts the same team changes together. Microservices would be an alternative to evaluate if concrete needs for independent deployment, operation or scaling came up. I wouldn't add them just to split up the design.
The relational database is a candidate because the example needs to relate users, teams and bookings and protect transactional changes. Not because “SQL is always better”. The specific choice still depends on what the company runs and on measured behavior.
The interfaces also need to be explicit. For example:
/appointmentsIn this contract, a successful response will mean booking confirmed, not just a request received for later processing:
{
"id": "apt_8f21",
"slot": "H-42",
"status": "CONFIRMED"
}A 202 Accepted would only promise that the request made it into the queue. That distinction shapes what has to happen before responding.
Picking a database doesn't, by itself, settle who can change the data or how to preserve the rules under concurrency. In our design, the Scheduling module controls bookings; Teams keeps the organizational links that authorization relies on. Sharing a database doesn't mean letting any module rewrite any state.
Picture Ana and Bruno checking slot H-42. Both get “available”. Ana confirms first, but Bruno is still looking at the earlier response.
If the application just checks and then writes, with no protection against concurrency, there's room for both operations to go through. Being inside a transaction doesn't automatically make every check-then-write flow safe; the and the constraints in use matter.
Without that protection, the race for H-42 ends like this:
For the fixed slots in this example, one solution would be to keep one active occupancy per slot, protected by uniqueness in the database. The transaction groups the changes: the booking and its occupancy are committed together or rolled back. Uniqueness constraints exist precisely to prevent duplicate values in the protected columns.
Both requests go through the same point of protection:
Whoever loses the race gets a conflict, not a confirmation that will be silently undone:
{
"code": "SLOT_UNAVAILABLE",
"message": "This slot was just booked. Please choose another one."
}On cancellation, the system releases the occupancy and keeps the booking's history. That's part of the model too.
Only for fixed slots
Uniqueness per slot works because each slot is a fixed opening. With free-form intervals, two bookings can overlap without starting at the same instant, and preventing identical start times isn't enough.
The confirmation has to respect the current occupancy. A management summary, on the other hand, could be updated with a few seconds of delay, if the product accepts it. Those are different commitments within the same system.
An availability cache could speed up queries, but it wouldn't replace the protected check at booking time: the copy may be out of date.
Permissions need a different kind of care. The API must check the user, the action and the resource being accessed. Hiding a schedule in the frontend doesn't protect a direct call to the backend. recommends validating authorization on every request.
Notice how the rule “a manager follows their team” turned into a decision about data source, query and access boundary, not just about which button shows up on screen.
Latency is how long an operation takes. Throughput is how much work the system completes per unit of time. Scalability is its ability to handle more load by adding resources while preserving the defined goals. A system can respond quickly with ten users and degrade as concurrency grows.
Scaling vertically means giving more resources to one instance. Scaling horizontally means adding instances. A load balancer spreads requests across them, but that doesn't remove a shared bottleneck in the database. We also need to avoid a request depending on state kept only in the memory of one specific instance.
Other pieces come in depending on the problem observed:
| Observed pressure | Option to evaluate | The price of the choice |
|---|---|---|
| Many repeated reads | Cache | Reduces hits on the source, but requires dealing with stale data. |
| Queries overload the primary database | Read replica | Spreads reads, but asynchronous replication can lag. |
| Peaks of work that can wait | Queue and workers | Smooth out peaks, but add waiting, reprocessing and monitoring. |
| Data or writes hit the limits of one node | Partitioning across nodes | Spreads load, but complicates queries and operations across partitions. |
These mechanisms solve distinct problems, and each one charges its own price.
In our example, I'd check queries, indexes and the amount of data returned before adding another layer. A full history returned on every access calls for a bounded response, possibly paginated, not necessarily more servers.
And a queue doesn't create infinite capacity. If work keeps arriving faster than the consumers process it, the delay grows.
Suppose that, at peak, confirmed bookings generate 20 emails per second and the provider accepts 10:
Ten minutes of peak leave 6,000 emails waiting. And more workers don't fix it: the limit is the provider, not whoever consumes the queue.
A design needs to explain what happens when its parts stop responding. Transient failures, timeouts and overload are part of the problem, and poorly controlled retries can make it worse.
In the booking system, the consequence depends on which part failed.
If the database is unavailable, we don't promise a new booking. Without recording the occupancy safely, responding “confirmed” would break the endpoint's contract.
If the email provider fails, the already confirmed booking stays valid. The persisted pending task makes it possible to try sending later. The worker needs to record success or failure, limit attempts and make old pending tasks visible to operations. In this initial design, a task table can play that role without a separate .
If the response is lost after the confirmation, the outcome is uncertain for the client. A timeout doesn't mean nothing happened. To allow safe retries, we can identify the request and recognize new attempts of the same operation, without producing another booking. This property is called idempotency.
With a key sent by the client, a retry stops being a new request:
Uniqueness protects the slot against two bookings; idempotency helps recognize the repetition of the same request. One doesn't replace the other.
The Google SRE book recommends monitoring latency, traffic, errors and . For scheduling, I'd add booking conflicts, the age of pending notifications and the time spent waiting for a database connection.
A rise in latency calls for investigation: slow processing, an external dependency or waiting on a busy resource? Metrics show aggregate behavior; tracing a single request helps locate where it spent its time.
The article on P50, P95 and P99 goes deeper into this reading. Here, it's enough to remember that “the average looks fine” doesn't end the evaluation.
We also need to agree on how long the operation can be unavailable and how much data loss is tolerable in a recovery. Those targets guide redundancy, backups and restore tests; a managed service doesn't remove the need to verify the result.
An architecture is a proposal to validate, not proof of capacity. For our example, the tests need to confront the design with the commitments it made:
Then come the costs no diagram shows: who gets the alert, restores the database, maintains the contracts and updates the dependencies. Evaluating an architecture has to account for that work, beyond the infrastructure bill.
And today's design has no fixed next step. Each signal calls for a different change:
Evolution responds to concrete limits and needs, not to a mandatory roadmap of technologies.
I'd record the proposal in a Design Doc and keep the relevant architectural choices in ADRs. For example: why does the booking need immediate confirmation, while the notification can happen later?
That justification matters when someone proposes putting the whole operation on a queue to “gain scale”. The change may be valid, but it alters the promise made to the user and needs to be discussed as such.
Before wrapping up, five questions on requirements, load, concurrency, failures and the promise made to the user:
All the goals below are about availability queries. Which one lets you evaluate whether the design meets it?
The booking system started with a screen, a slot and a button. The design took shape when we defined what that button needed to guarantee.
There was no need to start with microservices, a distributed cache and several queues. What it took was making responsibilities explicit, protecting the booking and separating the confirmation from the work that could wait.
Learning System Design is developing that reasoning: tying each piece to a requirement, recognizing the cost of the choice and knowing what evidence would make the design change.
On the next feature in your project, pick an important operation and follow its path. Who can run it? What state does it change? What happens if it arrives twice or if the response never comes back?
Before adding another box to the diagram, answer those questions. You'll already be doing System Design.
The components, the failures and the safe retries behind this article's design.
Software Architect and Engineer | Java & AI