Which layer owns a rule
"Where does this go?" is the question you will actually be asked in a full-stack codebase, and it has more wrong answers than right ones. Put a rule in the component and the API does not enforce it. Put it in four places and three of them go stale. Put it in the database and nobody can read it.
The honest answer is that some rules genuinely belong in more than one place — but each copy does a different job, and confusing those jobs is where the bugs come from.
Validation lives in three places, for three reasons
Take the rule "quantity must be a whole number between 1 and 20".
In the browser, for the person. Instant, specific, next to the field.
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 20) {
setError("Choose between 1 and 20.");
return;
}
This is user experience. It has no security value whatsoever, because it runs on a machine the customer controls.
In the API, because it is the trust boundary. This is the one that matters.
export class AddToCartDto {
@IsInt()
@Min(1)
@Max(20)
quantity!: number;
}
Anybody can send a request. curl bypasses your React form entirely, and so does
a customer who opens devtools. If the rule is not checked here, it is not
enforced.
In the database, as a last resort. A constraint catches the bug you have not thought of — a migration script, an admin tool, a second service written next year:
quantity Int // plus a CHECK constraint in the migration
Three copies, three jobs: helpfulness, enforcement, integrity. None of them is redundant, and if you are going to skip one, skip the first.
The failure mode to recognise: a rule checked only in the component. It feels complete because you cannot get the bad data in through the UI. That is exactly the bug that gets reported as "how did this order have 4,000 bags of atta in it?"
Business rules belong in the service, once
Validation is about shape. Business rules are about meaning, and they go in one place — the service layer:
// orders.service.ts — the only thing that may change an order's status
async setStatus(orderNumber: string, next: OrderStatus, opts?: { note?: string }) {
…
}
Not in the controller, which should only translate HTTP into a call. Not in the component. Not duplicated into the admin route because it was convenient.
The Kirana Store's payment webhook is the clearest illustration. Marking an order paid releases stock, frees a slot, writes an audit entry and sends a notification. The webhook handler does not do any of that itself:
await this.orders.setStatus(payment.order.orderNumber, "PLACED", {
note: "Payment received",
});
Three words, because the state machine already knows what a status change means. The alternative — the webhook doing its own version — is how the audit trail ends up with holes in it for exactly the orders that were paid for online.
The test: if two code paths can put the system into the same state, they must go through the same function. Otherwise they will diverge, and the one you use less often is the one that will be wrong.
What the client is allowed to send
The most important line in this lesson: never accept a value the server can work out for itself.
// wrong — the client sends the amount
async createIntent(orderNumber: string, amountPaise: number) { … }
A client that can send an amount can send 1. The Kirana Store reads it from the
order instead:
const order = await this.prisma.order.findFirst({
where: { orderNumber, userId },
select: { id: true, orderNumber: true, status: true, totalPaise: true },
});
…
const providerRef = this.gateway.createIntent(order.orderNumber, order.totalPaise);
The same applies to prices (read from the catalogue), the user id (read from the session, never the body), timestamps (the server's clock), order numbers, and totals. A DTO should contain only things the server cannot know: which product, how many, which slot, which address.
Note where: { orderNumber, userId } too. Not "find the order, then check it
belongs to them" — the ownership check is in the query. One statement, no
forgotten branch, and no timing window between the read and the check.
Server or client component
Next.js gives you a second version of the same question.
Server by default. Push "use client" as far down the tree as it will go — to
the one component that actually needs state or an event handler.
// app/(shop)/orders/page.tsx — server: fetches, renders, no JS shipped
export default async function OrdersPage() {
const orders = await getOrders();
return <OrderList orders={orders} />;
}
// components/cart-button.tsx
"use client";
export function CartButton({ productId }: { productId: string }) { … }
The mistake is marking a whole page "use client" because one button inside it
needs onClick. That ships the entire subtree to a mid-range Android on mobile
data, and it moves your data fetching into the browser, which means an extra
round trip and your access rules running somewhere the customer can watch.
And the rule that follows from it: a server component may read secrets; a
client component may not. Anything a client component can see is public,
including every NEXT_PUBLIC_ variable, which is baked into the bundle at build
time and is not a secret in any sense.
Where the layers meet
A short reference for the arguments that come up most:
| Rule | Lives in |
|---|---|
| Field required, in range, right type | DTO in the API — plus the form, for the person |
| "Cart must not be empty" | Service |
| "Stock must cover the order" | Service, inside the transaction |
| "This order belongs to this user" | The query's where, not a later if |
| Price, total, tax | Server, from the catalogue. Never the request |
| "Two orders cannot take the last bag of atta" | Database transaction |
| "An order number is unique" | Database constraint |
| Currency formatting | Shared package, used by both |
| "Show the Cancel button for 30 minutes" | Shared function, called by the component and the service |
That last row is the pattern worth remembering. The UI needs to know whether to
show a button; the API needs to know whether to allow the request. Same rule,
two callers, one function — in packages/shared. Written twice, they disagree
the first time somebody changes thirty minutes to an hour, and then the button is
there and the request fails.
Do not build layers you do not need
The counterweight, because this lesson could be read as a licence to add abstraction.
A repository class wrapping Prisma, which already is one. A service that only forwards to the repository. A mapper converting a type to an identical type. Each is a file to open before you find out what happens, and none of them is paying rent.
The Kirana Store has controllers, services and Prisma. Three layers, each with a job you can state in a sentence. Add a fourth when you can say what it does that none of the three could.
Check your work
Why validate in three places: the form is for the person, the API is the trust boundary, the constraint is for the bug you have not thought of.
Which one you must never skip: the API.
Where business rules go: the service, once, and two paths to the same state must share a function.
What a DTO may contain: only what the server cannot work out itself.
Why ownership belongs in the where: no forgotten branch, no timing window.
Server versus client components: server by default, "use client" as deep as
possible.
Why NEXT_PUBLIC_ is not a secret: it is baked into the bundle at build
time.
The shared-rule pattern: a rule both the UI and the API need goes in
packages/shared and is called from both.
When not to add a layer: when you cannot say what it does that the existing three could not.
Practice
- Pick a validation rule in your Kirana Store and find all the places it is enforced. State the job of each.
- Find one rule enforced only in a component. Add it to the DTO, then break it
with
curlto prove the gap existed. - Send a request with a
totalPaisethe server should not trust. Confirm it is ignored. - Find a check written as "fetch, then
ifit belongs to them". Move it into thewhere. - Find a
"use client"at the top of a page. Push it down to the component that needs it and measure the JS shipped before and after. - Put a real secret in a
NEXT_PUBLIC_variable, build, and search the output bundle for it. Then remove it. - Find a rule the UI and the API both know. Move it to
packages/sharedand call it from both. - Change the thirty-minute cancellation window in one place and confirm both the button and the endpoint follow.
- Find two code paths that set the same status. Make the second call the first.
- Find a layer in your codebase that only forwards. Say what it does that the layer below could not, and if you cannot, delete it.
Next: the contract between the two halves, and how to change it without breaking anybody.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship