How do you decide which input rules belong in a request model's declarative constraint set and which must stay in domain logic?
answer
- ask what the rule needs to know
- payload-only stays at the boundary
- state-dependent rules race
- groups versus separate models
- custom constraints must stay pure
basics
~20 sDeclare at the boundary what can be decided from the payload alone — presence, type-level shape, range, cardinality, cross-field agreement. Rules needing stored state, a lookup, or transactional certainty belong with the operation that can decide them atomically.
solid answer
~50 sThe test is **what the rule needs to know**. If it can be settled from the request payload alone — a field is present, a string fits a length or pattern, a number is in range, a list is bounded, two fields agree — it is a boundary rule, and declaring it keeps the check uniform and cheap. If it needs stored state, another service, or must hold at the instant of the write — the name is unique, the balance covers the amount — it belongs with the operation, because a boundary answer is stale by the time it matters. Two decisions follow: when one model serves several operations, choose between rule groups and separate models per operation, splitting once the differences are structural; and when a shape rule recurs, promote it to a custom constraint with one definition, test and message.
go deeper
Hold on to the simple split: format, presence, range and size are declared on the input model; anything that needs data from storage is decided by the code doing the operation.
Explain why the split exists — a boundary check against stored state can be out of date by the time the write runs — and know that cross-field rules are declared at the object level.
Show judgment on groups versus separate models per operation and on when a rule earns promotion to a reusable custom constraint, including what that costs a reviewer.
Own the policy: which invariants may live only in the operation, what duplication is deliberate defence, and how the team keeps a boundary-only invariant from being the system's real guarantee.
## The question behind the question "Where does this rule go?" is really "what does this rule need in order to be decided?" Answer that and the placement follows, and the answer does not depend on which framework you use. | The rule needs | Placement | Why | |---|---|---| | Only the payload | Declared constraint on the input model | Cheap, uniform, rejected before any work | | Two or more fields of the payload | Object-level custom constraint | Still payload-only; it just is not one field's rule | | Stored state or another service | Domain, inside the operation | A boundary answer is stale before the write | | To hold at the instant of the write | Domain, inside the transaction | Only the write's own scope can guarantee it | | Configuration or a policy that varies | Either, but the source must be injected | Placement follows who owns the policy | ## Why state-dependent rules must not migrate outward A boundary check against stored state answers a question about the past. Between the check and the write, another request can take the last unit of stock, claim the name, or move the entity into a state that forbids the operation. The check is not merely weaker there — it is misleading, because it makes callers and reviewers believe an invariant is held when only a race-prone sample of it is. The right structure is to let the operation enforce it where it can be enforced atomically, and to treat any boundary version as an optimisation that produces a friendlier early answer, never as the guarantee. ## Cross-field rules are still boundary rules A rule such as "if the payment type is card, the card details block must be present" needs nothing but the payload, so it belongs at the boundary even though it is not one field's business. Declaration styles handle this as an **object-level** rule evaluated against the whole model rather than a single field. Two design points matter: - **Attribute the failure to a field.** An object-level failure that says only "the request is invalid" is much less useful than one that points at the field the caller should change. - **Keep the condition in the model, not in the rule's implementation.** A rule that reaches outside the model to decide has quietly become a state-dependent rule and belongs with the operation. ## Groups versus separate models The same shape often serves create and update, or a public and an internal caller, with different requirements. Two mechanisms exist: 1. **Groups** — one model, each rule tagged with the situations it applies to, and the binding declares which situation this is. Cheap when the difference is one or two fields. 2. **Separate input models** — one per operation, each with its own rules. Groups keep a single definition but make the model harder to read: what actually runs is a function of a tag you have to look up at the binding site. Separate models duplicate the field list but each one states its own truth plainly. A workable rule of thumb is to start with groups for a single-field difference and split once the differences are structural, since a model whose rules only make sense in combination with a tag is a frequent source of the bug where a route validates under the wrong situation and half the rules silently do not run. ## When to build a custom constraint Promoting a rule to a reusable declared constraint pays when: the same shape rule appears on several models; the rule deserves a name in the ubiquitous language of the system; or it has enough edge cases to deserve its own tests. It costs when a one-off rule becomes an indirection that reviewers have to open in another file to understand. Practical guidance: - Reuse threshold of roughly three occurrences before promoting, unless the name itself is valuable. - Keep the implementation pure — payload in, verdict out — so it is unit-testable without a request. - Make the failure message and its identifier part of the constraint's definition, so every use reports identically. - Never let a custom constraint perform a lookup: that converts a cheap boundary filter into a per-request dependency, and reintroduces the staleness problem the boundary was chosen to avoid. ## Accept the deliberate duplication Some rules will exist in both places: the boundary rejects a negative amount early and the domain refuses a negative amount too, because the domain is also reachable from paths that never crossed the boundary. That is not a smell to be eliminated; it is defence at two altitudes with two different jobs. What *is* a smell is the inverse — an invariant that exists *only* at the boundary, because the first internal caller to bypass the boundary silently violates it.
- Is it ever right to check a state-dependent rule at the boundary as well?Yes, as an optimisation: an early lookup gives a friendly error without doing the work. It must be documented as advisory, and the operation must still enforce the rule atomically, because the boundary result can be stale by the time the write happens.
- What is the failure mode of using groups heavily?What runs becomes a function of the situation tag chosen at the binding site. Bind under the wrong one, or forget to tag a new rule, and a subset of rules silently stops running while the model still reads as fully constrained.
- How do you keep a shared custom constraint from becoming a dumping ground?Give it one clear meaning and a name from the domain vocabulary, keep it payload-only and side-effect free, test it directly, and split it when two call sites want different behaviour instead of adding a configuration flag.
saying these in an interview costs you the question
- Puts uniqueness or balance checks at the boundary and calls the invariant held
- Treats a cross-field rule as impossible to declare at the boundary
- Lets a custom constraint perform a lookup or call a service
- Considers duplication between boundary and domain always a smell
- Adds a group tag for every variation instead of splitting the model