One Error Shape, Checked at Every Layer
A user types 0 into "water every ... days". Where should that be caught: in the form, on the server, or in the database? The answer is all three, each for a different reason. And when the server catches it, the error must travel back in a shape the client can show right next to the field that caused it.
The rule
Check in the browser for speed, on the server for truth, in the database as the last line.
- Browser.
<input type="number" min="1" required>and small JavaScript checks give instant feedback without a round trip. They are a courtesy, and anyone can bypass them. - Server. Validate every field of every request. This is the check that counts, because it is the only one users cannot skip.
- Database. Constraints such as
NOT NULL,UNIQUEandCHECK (water_every_days > 0)catch what slips past: a bug in your own server code, or a route someone forgot to validate.
So far, Sprout's errors have been a bare message such as { error: 'Plant not found' }. That is fine for one sentence, but it gives the client nowhere to put "this field is wrong". Grow it into one shape for the whole API:
{
"error": {
"code": "validation_failed",
"message": "Some fields need fixing.",
"fields": { "water_every_days": "Must be at least 1 day." }
}
}const days = Number(req.body.water_every_days);
if (!Number.isInteger(days) || days < 1) {
return res.status(422).json({ error: {
code: 'validation_failed',
message: 'Some fields need fixing.',
fields: { water_every_days: 'Must be at least 1 day.' },
} });
}Because every error looks the same, one helper on the client handles them all: message goes into a banner, and each entry in fields goes under its input.
Unexpected failures need care too. When something truly breaks, answer 500 with a short, generic message and write the details to the server's log. Stack traces and SQL in a response help attackers, not users. Some database errors are really user errors in disguise: a UNIQUE violation on email (PostgreSQL error code 23505) should become 409 with "That email is already registered".
The shortcut
Place each rule by asking "Who would break it?" An honest typo: the browser catches it first, the server again. A modified client or a hand-made request: the server. Your own bug, or a path nobody validated: the database constraint.
An honest typo
The form's
min="1"blocks the submit and shows a hint at once. No request is sent.
Drills
Answer fast — trust the rule.
Which layer's check can Sprout never skip?
Why keep a database CHECK when the server already validates?
What should the body of a 500 response contain?
How does one error shape across the whole API help the client?
Sign-up hits the UNIQUE constraint on users.email. What should the API answer?
Remember
- Check in the browser for speed, on the server for truth, in the database as the last line.
- Shortcut: "Who would break it?" A typo, a hand-made request, or your own bug each meets a different layer.
- Trap: leaking stack traces in 500s, and inventing a new error format for every route.
