A command object like `TransferFunds(fromAccountId, toAccountId, amount)` passes through validation before its handler runs any business logic. What kinds of checks belong in that pre-dispatch validation step, versus what should be left for the handler to check against live state?
answer
- structural vs domain validation
- self-validating command constructor
- fail fast before dispatch
- stale-check race if done too early
- invariants checked inside the transaction
basics
~20 sBefore dispatch, check the command's shape is sane - no missing fields, no negative amount. Inside the handler, check things that depend on current data, like whether the account actually has enough money right now.
solid answer
~50 sPre-dispatch validation is structural or syntactic and needs no external state: required fields present, amount positive, IDs well-formed, string lengths within bounds. This can live in the command's own constructor as a self-validating command, or in a dedicated validation layer before the command is even accepted onto the bus, so garbage never enters the pipeline. Handler-side validation is business or domain validation that depends on current aggregate state: does the source account exist, does it have sufficient balance, is it frozen, would this transfer breach a daily limit. That check must happen inside the handler, right before applying the change, because it depends on data that can change between an earlier check and execution - checking it too early would be stale by the time the handler runs. The split matters for concurrency: only the handler's in-transaction check is safe against race conditions.
go deeper
Can name at least one example of a structural check, like a missing field, and one example of a business check, like insufficient balance.
Explains the split between structural and domain validation and why domain checks must happen at write time, not earlier.
Discusses the race-condition risk of checking business rules outside the transaction, and where self-validating command constructors fit.
Can talk about validation as a layered pipeline across a distributed system - API gateway schema checks, command bus structural checks, handler domain checks - and the cost of getting the layering wrong, either rejecting too late or trusting stale reads.
## Two layers, split by one question Command validation naturally splits into two layers that differ in one crucial way: whether the check needs to look at anything outside the command itself. - **Structural validation**, sometimes called syntactic validation, asks only 'is this message well-formed' — are all required fields present, is the amount a positive number, are the account IDs the right shape, is the currency code valid. None of these checks touch a database or any other service; they can be answered purely by looking at the command object's own fields. - **Domain validation**, by contrast, asks 'is this true about the world right now' — does the source account actually exist, does it have enough balance, is it frozen, would this transfer push the sender over a daily limit. Answering any of these requires reading current state, which means domain validation cannot be fully resolved until the moment the handler actually runs against a loaded aggregate. | Structural validation | Domain validation | |---|---| | asks whether this message is well-formed | asks whether this is true about the world right now | | answered purely by looking at the command object's own fields | requires reading current state | ## Why the layering exists This layering exists to fail fast and cheap. Rejecting a malformed command before it ever reaches a handler, a database connection, or a message bus saves real cost: no wasted transaction, no wasted lock, no noise in domain-level error logs for what is really just a client bug. - **Self-validating commands** push this further by making it structurally impossible to construct an invalid command object at all — if the constructor throws on a negative amount, no downstream code path can ever receive one. - **Keeping structural checks separate from domain checks** also keeps each concern testable in isolation: structural rules can be unit-tested against the command class with no infrastructure, while domain rules are tested against the aggregate's behavior. ## The trade-off is where the line gets drawn The trade-off is where the line gets drawn, and it is easy to draw it wrong in both directions. 1. **Push too much into structural validation** — for example, checking 'does this account exist' before dispatch, using a stale read from a cache — and you either reject valid commands based on outdated information or, worse, let commands through that will fail once real domain state is consulted, forcing the handler to redo the same check anyway. 2. **Push too little in**, skipping structural validation entirely and letting every check happen inside the handler, and you pay full transactional and aggregate-loading cost just to reject a command that was missing a required field, which is wasted work at scale and a worse failure experience for the caller, who gets a generic domain error instead of a precise 'amount must be positive.' ## The most dangerous failure mode The most dangerous production failure mode is treating a domain check as if it were structural and evaluating it too early, outside the transaction that will perform the write. If a pre-dispatch validator checks 'source account balance is at least $50' by reading the balance once, and the actual debit happens later in the handler, a second transfer can drain that same balance in between the two, and the earlier check's approval is now stale — the handler must **re-verify the balance atomically with the debit itself**, typically by re-reading it inside the same database transaction, or via optimistic locking with a version check on write. Skipping this re-check is exactly how systems end up allowing accounts to go negative under concurrent load, even though every individual request 'passed validation.' ## Walking one transfer through both layers A concrete example from a banking-style `TransferFunds` flow: the API gateway or the command's own constructor rejects a request with a missing `toAccountId` or a zero amount immediately, before any database call is made — pure structural validation, resolved in microseconds with no I/O. The command then reaches the `TransferFunds` handler, which loads the source account aggregate inside a database transaction, checks its current balance and frozen status against the requested amount — domain validation, resolved against live, locked data — and only then applies the debit and commits. If the balance check had instead been done as a separate pre-dispatch step five seconds earlier, a concurrent withdrawal in those five seconds could have already emptied the account, and the transfer would proceed on outdated information unless the handler independently re-validates at write time. ## What each rejection tells the caller A further wrinkle worth naming explicitly: structural validation errors and domain validation errors usually deserve different status codes and different retry advice to the caller. - **A missing field or a malformed ID is a client bug** — retrying the exact same command will fail identically every time, so the correct response tells the caller to fix its request, typically surfaced as a 400-class error listing every violated field at once rather than one at a time. - **A domain rejection like insufficient balance is not a client bug in the same sense**; the command was perfectly well-formed, it simply couldn't be honored given the current state of the world, and a caller might reasonably retry later if the state changes, for example after a deposit clears — so many teams surface these as a distinct error family, sometimes called business or domain errors, precisely so client code and monitoring dashboards can tell 'you sent garbage' apart from 'the world said no' without parsing free-text messages.
- Where should structural validation live - in the command object's constructor, or in a separate validator class?Either works; self-validating commands, throwing in the constructor if fields are missing or malformed, keep invalid commands from ever existing as objects, which is a strong invariant. A separate validator is more flexible when the same structural rules need reuse across command types or need to produce a structured list of errors instead of throwing on the first bad field.
- Should you re-validate structural rules inside the handler too?Not usually - that's redundant work and a maintenance burden if the two checks drift apart. The handler should trust that anything reaching it already passed structural validation and focus entirely on domain rules that need live state.
- What happens if you put a business rule like 'sufficient balance' into the structural pre-dispatch check instead of the handler?You introduce a race condition: another transfer could drain the balance between your pre-check and the handler's actual write. The check has to be re-evaluated atomically with the write itself, typically inside the same database transaction the handler uses to persist the result.
It's like airport security versus passport control at the gate. Security, structural validation, just checks your bag isn't obviously broken - no liquids over 100ml, no missing boarding pass. Passport control, domain validation, checks something that can only be verified right now, at the gate, against the live database - is this passport currently valid, is this seat still available. Checking passport validity a week before the flight wouldn't catch a passport that expired yesterday.
saying these in an interview costs you the question
- Puts balance, inventory, or uniqueness checks in a pre-dispatch validator instead of inside the handler's transaction
- Can't distinguish 'is this field present and well-formed' from 'is this true about the world right now'
- Thinks validating early removes the need to check again atomically at write time
- Has no answer for what happens when a validly-shaped command turns out to violate a business rule