Validate at the Boundary and Nowhere Else
The review found validation in the controller, again in the service, and once more near the database, three slightly different checks that had drifted apart over a year, and none of them on the path that mattered, because the nightly import job called the service directly and skipped the controller, and the service's copy of the check was the stale one. The system had abundant validation and one unvalidated entry point, which is the same as one unvalidated entry point.
Scattered validation feels like defence in depth and behaves as defence in none, because each layer's check is a guess about what the other layers do, and the guesses disagree, and every entry path composes a different subset of them. The discipline that fixes it is older than the frameworks: validate at the boundary, once, and make the interior trust only validated shapes.
This is the checklist I run on any diff that touches input handling, with the reasoning for each item.
The boundary is where untrusted becomes trusted
A system has a small number of places where data crosses from untrusted to trusted: the HTTP edge, the queue consumer, the file importer, the webhook receiver, the CLI argument parser. Every one of those is a boundary, and the rule is that data is validated exactly once, at the boundary it enters, into a typed, constrained shape, and everything interior consumes only that shape.
The review question is not "is there validation" but "where does untrusted data become trusted, and is that the only place". If the answer names three places, the system has three opinions about validity, and the interior code, which trusts its inputs, will be fed an invalid one by whichever opinion lost.
Why interior validation is the defect
Validation inside the business logic is not redundant safety. It is actively harmful, in three specific ways.
It rots in disagreement, because each copy evolves with its layer, and the copies diverge, and the divergence is a bug that only appears on the paths that hit the differing copy, which is the import job's path in the opening incident.
It lies about trust, because a service method that revalidates its arguments is a method that does not trust its callers, which means its callers include unvalidated ones, which means the boundary is leaking. The interior check is the symptom of a missing boundary, not a second line of defence.
And it inverts the failure mode, because interior validation failures are exceptions deep in the stack, at two in the morning, with no request context, while boundary failures are clean four hundred responses at the edge with the offending field named. The same invalid input produces a handled rejection at the boundary and an incident in the interior.
The shape that makes the interior safe
The boundary should parse into a typed shape that makes invalid states unrepresentable, not check and then pass the raw data inward. An email that is a validated email type, a positive amount, an enum from a fixed set, a bounded string, so that the interior code cannot receive the invalid value, because the type does not contain it.
This is the allowlist discipline from mass assignment and SQL injection generalised: the boundary is where attacker written input becomes a constrained value, and the constraint is a type, not a hope.
The parse, not validate, framing matters, because validation returns the same data with a boolean, which can be ignored, while parsing returns a new value or fails, which cannot be bypassed by forgetting to check.
The internal callers are boundaries too
The defect in the opening incident was an internal caller that was not treated as a boundary. The import job, the queue consumer, the cron, each crosses a trust edge, because their input arrives from files, queues and schedules that can carry the same garbage and malice as the HTTP edge, and the queue that a partner writes to is an HTTP edge wearing a broker's clothes.
The review must enumerate the entry points, not assume the edge is the only one, and each gets the same parse at its door. The interior service then has one contract: it receives parsed shapes, from every caller, and the import job parses at its own boundary like everyone else.
What the interior may still check
The rule is not that the interior is naive. The interior checks invariants, the business rules that relate values to each other and to state, an order cannot ship before payment, a transfer cannot exceed the balance, which are not input validation but domain logic, and they belong in the logic because they depend on state the boundary cannot see.
The distinction is the review's compass: the boundary checks shape, the interior checks meaning. An interior check of shape is the defect. An interior check of meaning is the business.
The review comment that lands
Not "add validation here too". Instead: "this service method revalidates its input, which means some caller is unvalidated, and the import path is that caller. Move the parse to the two boundaries, into a typed shape, and let the service trust its arguments. The interior keeps the balance check, which is meaning, not shape."
That comment names the leak, the fix and the line between validation and logic, and it treats the scattered checks as the smell of a missing boundary, which is what they are.
The rule
Untrusted data becomes trusted exactly once, at the boundary it enters, by parsing into a typed shape the interior cannot receive invalid, and every entry point, HTTP, queue, file, cron, is a boundary with the same parse. The interior checks meaning against state, never shape against guesses.
Three drifting validators are not depth. They are three outdated maps of a border that was never fenced, and the one unfenced gate is the one the traffic finds. The caching cousin of the same lesson, where the omitted dimension is the boundary, is the cache that returned another customer's data.