Every web app eventually ships the same bug: the frontend correctly greys out an option, and someone sends the request anyway. A discount that shouldn't apply, a delivery window that's closed, a price from a stale cached menu. The UI did its job. The system didn't. On Saturdays — a food delivery platform with a customer app, a restaurant surface, a rider layer and an operator console all hitting one backend — I settled this with one rule, applied everywhere.
A disabled button is a suggestion
It's worth being precise about what the client is. Your React app is one of many things that can call your API. So is curl. So are the browser's dev tools, an old cached build, a request retried by a flaky network — and, in Saturdays' case, an entirely separate product: the DineGuru restaurant POS, which talks to the same backend over its own integration.
Anything the frontend enforces, every one of those other callers skips. Client-side validation is for the person using the screen: fast feedback, fewer wasted round trips. It is not a control. An interface can only ever hide an option; it cannot stop a request.
The rule
The line I drew is simple enough to apply without a meeting:
If getting it wrong costs money or misleads a customer, the server decides and the client is told.
In practice that put a specific list of decisions in the domain layer rather than the interface:
- Prices and taxes — computed from the order, never read from the request.
- Payment amounts — the endpoint takes an order id, not a number.
- Charge waivers — such as the dine-in charge waiver — decided by server rules, not a checkbox.
- Rider-assignment signals — the customer sees a rider when the server has one, not when a timer guesses.
- Delivery-time limits — enforced where the order is created, not merely where it's displayed.
One implementation per decision
The rule only holds if each decision is made in exactly one place. Saturdays is sixteen Django domain apps, and the failure mode in a codebase that size isn't "nobody validates." It's three slightly different validations in three views, drifting apart one bug fix at a time.
So each decision lives in a service module that every caller goes through — the customer checkout, the partner portal, the POS integration, the payment webhook. When a tax rule changes, it changes once, and every path that reaches it gets the new behaviour on the same deploy.
The frontend doesn't become dumb in this model. It still validates with typed schemas at the boundary, still renders sensible defaults, still hides what doesn't apply. It just stops being the thing anyone relies on for correctness.
The takeaway
Sort your rules into two piles. UX rules — formatting, hints, what's shown — belong in the client, where they make the product feel fast. Business rules — anything a malicious, stale or automated caller could exploit — belong on the server, in one place, called by everyone. The test isn't "does my app ever send a bad request?" It's "what happens when something that isn't my app does?"
How that plays out across the whole platform — the order state machine, the payment gate, the POS boundary — is in the case study.
👉 Case study: Saturdays, a food delivery platform
Divyakush Punjabi — Full-Stack & AI Systems Engineer
🌐 https://www.divyakush.com · 💼 LinkedIn · 💻 GitHub
Top comments (0)