Input validation often begins as a few sensible checks and slowly spreads through a codebase. A controller checks that an amount is positive. A service checks it again. A helper receives the same primitive value and checks it a third time because it cannot tell whether the earlier checks ran.
The problem is not that validation is useless. The problem is that the program keeps carrying data in a form that does not record what has already been established.
A stronger approach is to parse untrusted input into trusted domain values at a boundary. Parsing here means more than converting text to a number. It means attempting to construct a value that represents a useful guarantee. If construction succeeds, downstream code can rely on that guarantee instead of repeatedly asking whether the raw value is valid.
This article develops that mental model, shows how it changes program structure, and explains where its guarantees stop.
Treat validation as a transition between two states
Suppose an API accepts a quantity for an order line. At the transport boundary, the quantity might be any integer:
quantity: -3The business rule says an order quantity must be between 1 and 100.
A common design keeps quantity as an integer and validates it before important operations:
addLine(productId, quantity):
if quantity < 1 or quantity > 100:
return error("invalid quantity")
...This check protects addLine, but the type and shape of quantity have not changed. Any later function receiving an integer must either trust its caller or repeat the check.
Instead, think of validation as a state transition:
integer -> attempt validation -> OrderQuantityOrderQuantity is a domain value that can exist only when the range rule holds. The boundary attempts to create it:
OrderQuantity.parse(value):
if value < 1 or value > 100:
return error("quantity must be between 1 and 100")
return OrderQuantity(value)The exact syntax depends on the language. The important design choice is that callers cannot freely construct an OrderQuantity that bypasses the rule.
Now the business operation accepts the stronger value:
addLine(productId, quantity: OrderQuantity):
...The function no longer needs to ask whether the quantity is in range. Successful construction already established that fact.
Put uncertainty at the boundary
A boundary is a place where data crosses from a less trusted representation into code that wants stronger assumptions. HTTP requests are obvious boundaries, but they are not the only ones. Command-line arguments, configuration files, messages, imported files, user forms, and data read from systems with weaker guarantees can all be boundaries.
At such a boundary, separate three questions:
- Can the raw representation be decoded?
- Does the decoded value satisfy the domain rule?
- If it does, what representation should carry that guarantee forward?
For an incoming order request, the flow might be:
JSON text
-> decoded request fields
-> OrderQuantity.parse(request.quantity)
-> OrderQuantity
-> order serviceEach step removes a kind of uncertainty. JSON decoding establishes that the payload has the expected syntactic shape. Domain parsing establishes that the quantity satisfies the rule needed by the application. The resulting domain value carries that fact into later code.
Keeping these steps distinct also improves error handling. Malformed JSON and a well-formed quantity of 0 are different failures. The first concerns representation; the second concerns a business invariant.
Make invalid construction difficult, not merely discouraged
The design is useful only if downstream code cannot casually create an invalid value.
Imagine this interface:
OrderQuantity(value)If any caller can invoke that constructor with -3, the type provides little protection. The parser may be correct, but it is only one optional path.
A stronger design exposes a checked construction operation while keeping unchecked construction private or otherwise restricted:
OrderQuantity.parse(integer) -> Result<OrderQuantity, QuantityError>After successful parsing, code may expose the underlying integer for calculations, but it should preserve the invariant when producing another OrderQuantity.
For example, adding two individually valid quantities can exceed the maximum:
60 + 60 = 120So this operation cannot simply promise another valid OrderQuantity. It must either fail when the result exceeds 100, return a different type, or model a different business concept.
This is an important boundary condition: a validated value stays trustworthy only while operations preserve its invariant.
Use the smallest domain value that earns its cost
Not every primitive deserves a wrapper. The technique is valuable when a guarantee matters to several parts of the program or when violating it would create confusing downstream behavior.
Good candidates often have rules such as:
- an identifier must be non-empty and follow a known format;
- a percentage must stay within an accepted range;
- a date interval must have an end that is not before its start;
- a retry count must be non-negative and capped;
- a money amount must use a supported currency and representation.
The rule does not have to describe one primitive. A trusted value can validate relationships among fields.
Consider a booking period:
BookingPeriod.parse(start, end):
if end < start:
return error("end must not be before start")
return BookingPeriod(start, end)Downstream code that receives BookingPeriod can rely on the ordering relationship. If it instead receives two independent dates, every consumer must remember the relationship itself.
The benefit is not the wrapper object. The benefit is moving a meaningful assumption from comments and repeated conditionals into a construction rule.
Distinguish structural invariants from changing business decisions
Some checks fit trusted domain values well because they are stable properties of the value. Others depend on information that changes over time.
Suppose a coupon code must be non-empty and normalized to uppercase. Those properties can be established when constructing a CouponCode.
But whether that coupon is currently redeemable may depend on the current time, customer, campaign status, usage history, or an external service. A CouponCode value cannot permanently guarantee those facts merely because they were true when it was created.
Keep the distinction clear:
CouponCode.parse(rawText)
-> establishes stable properties of the code
promotionService.canRedeem(code, customer, now)
-> evaluates current business stateTrying to encode every business decision into construction produces misleading guarantees. A value should promise only properties that remain true for its lifetime, or properties that its operations deliberately preserve.
Decide what to do with already-stored data
Boundary parsing is easiest when all data enters through one controlled path. Existing systems are often less tidy.
A database may contain rows created before a new invariant existed. Another service may write directly to the same store. A migration may temporarily permit both old and new representations.
In those cases, reading persisted data is also a trust boundary unless the storage layer itself enforces the same invariant.
Do not silently construct a trusted value from historical data merely because it came from your database. Choose an explicit policy:
stored row
-> checked reconstruction
-> trusted value
-> or explicit data-quality failureAlternatively, clean and constrain the stored data first so the persistence layer provides the required guarantee. Which approach is appropriate depends on ownership, migration risk, and how invalid historical records should be handled.
The key is to avoid claiming more trust than the system actually has.
Return useful failures from parsing
Parsing moves failure earlier, so its errors become part of the boundary design.
A boolean result such as isValid = false often loses information that a caller needs. Prefer an error that explains which guarantee could not be established:
OrderQuantity.parse(0)
-> QuantityTooSmall(minimum = 1)
OrderQuantity.parse(140)
-> QuantityTooLarge(maximum = 100)The domain error does not need to contain presentation text. An HTTP adapter might map it to a client-facing message, while a batch importer might record it alongside a rejected row.
This keeps the domain rule independent from one delivery mechanism while still making failures actionable.
Avoid turning parsing into duplicated validation
A common mistake is to introduce a domain type but keep the old defensive checks everywhere:
ship(quantity: OrderQuantity):
if quantity.value < 1:
return error(...)If OrderQuantity truly guarantees the range and no operation can violate it, this check adds noise rather than safety. It also suggests to readers that the type cannot be trusted.
Another mistake is the opposite: removing checks before the construction path is controlled. If tests, deserializers, persistence mappers, or internal helpers can still create invalid instances, downstream assumptions are premature.
Change the design in this order:
- define the invariant;
- establish one checked construction path;
- restrict bypasses;
- update callers to use the trusted value;
- remove redundant downstream checks only after the guarantee is real.
That sequence makes the causal relationship clear. Checks disappear because invalid states have become harder to represent, not because validation was declared unnecessary.
Know when a simpler check is better
Trusted domain values introduce names, constructors, error types, and conversions. That cost is justified when the guarantee reduces repeated reasoning across meaningful parts of the system.
A local check is often simpler when a value is used once, the rule is trivial and local, or the surrounding language and framework already express the constraint clearly. Wrapping every string and integer can bury business logic under conversion code.
The useful question is not, “Can this primitive become a type?” It is, “Would carrying this guarantee forward remove uncertainty that multiple callers otherwise have to manage?”
If the answer is no, keep the simpler design.
Use trusted values to shrink the reasoning surface
The practical advantage of parsing at boundaries is that it divides the program into two regions.
Near the boundary, code deals with malformed, missing, out-of-range, and otherwise uncertain input. After successful construction, core logic works with values that express stronger assumptions.
That separation reduces the number of places where developers must remember the same rule. It also makes function signatures more informative: OrderQuantity communicates more than integer, because it tells the reader what has already been established.
The approach does not eliminate validation, and it cannot freeze facts that depend on changing external state. It gives validation a clearer job: establish a durable invariant once, represent that invariant explicitly, and let downstream code rely on it for as long as the representation preserves it.