A booking system can survive almost any failure gracefully. It can be slow. It can lose a draft message. It can forget which page you were on. There is exactly one thing it cannot do, and that is give the same slot to two people.
That failure is not a database row. It is two customers arriving at 2pm, one of whom is turned away by a business that did nothing wrong. It costs the appointment, the trust, and usually the customer. Everything below exists to make it impossible rather than unlikely.
The naive version, and why it always breaks eventually
The obvious implementation is two steps: check whether the slot is free, then write the appointment.
is 2pm free? -> yes -> insert appointment
This works perfectly until two requests arrive close together, which on a busy Friday they will.
10:04:03.100 request A: is 2pm free? -> yes
10:04:03.140 request B: is 2pm free? -> yes
10:04:03.190 request A: insert 2pm -> ok
10:04:03.240 request B: insert 2pm -> ok
Both checks ran before either write landed, so both saw an empty slot and both were told the truth at the time they asked. Nothing here is a bug in the ordinary sense. The gap between reading and writing is simply a place where the world can change, and no amount of careful application code closes it, because the application is not where the decision is finally made.
Guard one: the database has the last word
The real guard is a partial unique index on the appointments table, across the business, the date, and the time.
create unique index appointments_no_double_book
on public.appointments (profile_id, date, time)
where status not in ('cancelled');
Two things about this are deliberate.
It is enforced by the database, not by the code that calls it. Any path that ever writes an appointment is covered, including one written next year by someone who has not read this article. A rule that lives in one function protects one function; a rule that lives in the schema protects the table.
It is partial, excluding cancelled rows. A cancelled appointment should not hold a slot hostage. Without the where clause, cancelling at 2pm would block anyone from ever booking 2pm again, which is a different and equally annoying bug.
When two requests race through the application check, the second insert now fails with a unique violation, Postgres error 23505. The API turns that into a 409 Conflict, and the customer is told the slot has just gone and offered the next ones. The race still happens. It simply stops mattering.
Guard two: check first anyway
If the index catches everything, why keep the application-level check at all?
Because the index produces a correct answer at the worst possible moment. A customer who has typed their name, chosen a service, and pressed confirm should not discover the problem at the final step. The application check catches the ordinary case, where the slot was taken ten minutes ago, and tells the customer while they are still choosing. The index catches the rare case, where it was taken 40 milliseconds ago.
One is for the common path and exists for the customer. The other is for correctness and exists for the data. Systems that keep only the second are correct and unpleasant. Systems that keep only the first are pleasant and wrong.
Guard three: the slot has to have been real
There is a subtler way to double-book that no index can catch, and it only appears once a language model is in the loop.
If the model is allowed to produce the appointment details, it can produce a time that sounds plausible and was never free. Everything downstream would then be perfectly consistent about a booking that should not exist.
So the model never supplies the slot. An appointment can only be created from a hold that the server itself validated during the conversation, and the hold has a lifetime. No hold means there is nothing to confirm, and the request is refused. A hold that has expired is refused too, rather than quietly used, because a stale hold is exactly the case where the slot was reserved five minutes ago and taken since.
This is the difference between a system where the model decides and a system where the model asks. Only one of them can promise anything.
Guard four: success has to be stated, not assumed
The last guard is about how the answer is read rather than how it is produced.
It is tempting to treat a request that did not obviously fail as a request that worked. That reasoning is wrong in a specific and dangerous way: a network timeout, an unexpected response shape, and a rejected write all look the same to code that only checks whether an error was thrown. Treating any of them as success produces the worst possible outcome, which is a confirmation for an appointment that does not exist.
So confirmation is positive. The response has to parse, and it has to say so explicitly, before anything tells the customer they are booked. Anything else is a failure, and the failures are distinguished from one another because the customer needs different things from each:
The slot was taken while you were typing. Here are the next three.
Your hold expired. Here is the same slot if it is still free.
That item is out of stock.
Something went wrong and we do not know what. Nothing has been booked.
The last one is the important one. A system that cannot tell you what happened should say so, not guess in the direction that makes the interface look tidy.
Businesses that can take two at once
Not every business serves one customer per slot. A salon with three chairs can genuinely take three appointments at 2pm, and the schema should not decide otherwise on their behalf.
Capacity is a property of the business rather than an assumption in the code. Where a business can serve several customers at once, the check counts existing appointments against that capacity and returns a conflict at the limit rather than at one. The guarantee is unchanged: nobody is ever given a place that does not exist.
What it costs
All of this adds an index lookup and one extra round trip, which is a few milliseconds on a request that a human experiences as instant.
The alternative costs an appointment, a difficult conversation on the day, and a customer who tells other people about it. That trade needs no analysis.
If you want the whole architecture rather than this one guarantee, the architecture of agency and transactional LLMs covers the full system, and three things that break when an AI agent starts booking covers the other two failure modes that show up in production.
Ordina's booking engine is built on these guarantees, so a confirmed appointment is one that genuinely exists. You can see businesses running on it in the Ordina directory.



