A request enters an application with an email address, a quantity, and a delivery method. The request handler validates all three fields. Later, the pricing code checks the quantity again. The notification code checks the email again. A background job checks the delivery method again.
The system has validation, but developers still cannot tell which values are safe to use without checking them first.
A more useful design goal is to make validation change what the program knows about the data. Unchecked input crosses a boundary, validation establishes specific facts, and successful validation produces a representation that preserves those facts. Code after that boundary can then rely on the representation instead of repeatedly rediscovering the same conditions.
This article explains that mental model, how to apply it without building an elaborate type system, and where validation still needs to happen again because the underlying facts can change.
Validation should establish a fact
Consider a function that sends an order confirmation:
function sendConfirmation(emailText):
if not looksLikeEmail(emailText):
return InvalidEmail
mailer.send(emailText)This function protects the mailer from one bad input. But if five functions accept the same raw emailText, each function must decide whether validation has already happened. The value itself carries no evidence.
Now separate unchecked text from a validated value:
function parseEmail(text):
if not looksLikeEmail(text):
return InvalidEmail
return EmailAddress(text)
function sendConfirmation(emailAddress):
mailer.send(emailAddress.value)The important change is not the class or wrapper named EmailAddress. It is the boundary:
raw text -> validate -> EmailAddressOnly the validating operation creates an EmailAddress. Once construction succeeds, code that accepts an EmailAddress can assume the local email-format rule has been satisfied.
This is a small example of a broader principle: represent established facts in the data you pass onward.
Keep unchecked data outside the trusted core
External data usually arrives in a weak representation: strings, numbers, maps, decoded JSON objects, form fields, environment values, or messages. These representations are useful for transport because they can describe both valid and invalid inputs.
They are less useful inside business logic when every operation needs stronger assumptions.
Suppose a checkout request contains this transport shape:
{
"quantity": 3,
"delivery": "express"
}The transport decoder may prove only that quantity is a number and delivery is text. The checkout rules need more:
quantity must be an integer from 1 through 20
delivery must be either standard or expressA boundary function can establish those rules and construct stronger data:
function parseOrderRequest(input):
quantity = parseQuantity(input.quantity)
delivery = parseDeliveryMethod(input.delivery)
if quantity is invalid or delivery is invalid:
return validation errors
return OrderRequest(quantity, delivery)After that conversion, the ordering code does not need to ask whether quantity is zero or whether delivery contains an unknown string. Those cases cannot be produced by the approved construction path.
The practical payoff is simpler reasoning. A function’s parameter tells the developer which assumptions are already established.
Validate at the boundary where an assumption becomes necessary
“Validate at the boundary” can be misleading if it is interpreted as “validate everything at the outermost API endpoint.”
Different parts of a system know different rules.
An HTTP adapter might know that a field must be an integer. The ordering domain might know that the quantity must be between 1 and 20. A warehouse integration might impose a separate maximum package size. Trying to perform every check in the HTTP layer pushes domain knowledge into the transport layer.
A better question is:
Where does this assumption become meaningful and enforceable?
For example:
HTTP input
|
| decode syntax
v
request data
|
| enforce ordering rules
v
valid order command
|
| check current warehouse capability
v
fulfillment decisionEach boundary establishes facts owned by that part of the system. This keeps validation close to the rules it protects while still preventing unchecked values from spreading farther than necessary.
Distinguish stable facts from facts that can become stale
A validated representation is useful only for facts that remain true for the lifetime in which you rely on them.
If Quantity can only represent integers from 1 through 20, that property does not become false while the value is passed between functions. It is a good candidate for construction-time validation.
Other facts depend on changing external state:
- a username is currently available;
- an account currently has enough balance;
- an access token has not expired;
- an inventory item is currently in stock;
- a referenced record still exists.
Checking one of these facts does not make it permanently true.
Suppose checkout performs this sequence:
if inventory.hasStock(item, quantity):
payment.charge(total)
inventory.remove(item, quantity)A successful hasStock check does not create a timeless guarantee. Another operation may consume the stock before remove runs. Wrapping the item in a ValidatedInStockItem would be misleading unless the system also reserves the stock or otherwise guarantees the condition remains true.
This distinction prevents a common design error: treating every successful check as a permanent property of a value.
Use stronger representations for stable local invariants. For changing facts, use the concurrency, transaction, reservation, or re-check mechanism required by the actual system.
Do not confuse parsing with business authorization
Converting raw input into trusted data narrows what a value can mean. It does not prove that every operation involving that value is permitted.
For example, parsing AccountId("A123") can establish that the identifier has the expected form. It does not prove that:
- the account exists;
- the current user may access it;
- the account is active;
- a requested operation is allowed.
Those facts depend on context and often on current state. They belong at the boundary where that context is available.
This separation makes both kinds of checks clearer. Structural validation answers, “Is this value well formed for our model?” Contextual rules answer questions such as, “May this actor perform this operation now?”
Avoid boolean validation that throws away useful information
A validator shaped like this is easy to write:
if isValid(request):
process(request)The boolean says whether validation passed, but it discards two useful things: the reason for failure and the stronger representation available after success.
A boundary that returns either errors or trusted data carries more information:
result = parseOrderRequest(input)
if result is Invalid:
return result.errors
process(result.orderRequest)The exact mechanism can be an exception, result type, tuple, discriminated union, or another convention supported by the language. The engineering principle is independent of that choice: successful validation should give downstream code something that is safer to rely on than the original unchecked value.
For user-facing input, collecting several independent errors can also be useful. A form may report an invalid quantity and missing delivery method together rather than forcing two submit-and-fix cycles. That error-reporting decision does not change the trusted-data boundary; construction still succeeds only when the required invariants hold.
Protect construction paths
A validated representation loses its meaning if callers can bypass its rules.
Imagine this API:
Quantity(value)
parseQuantity(value)If any caller can directly construct Quantity(-4), then a parameter of type Quantity does not establish the promised range. Developers must validate again, defeating the purpose.
The implementation technique depends on the language. Possible approaches include private constructors, factory functions, module visibility, smart constructors, or conventions enforced by code review. The important guarantee is simple:
Every normal construction path must preserve the invariant the representation claims.
Do not overstate that guarantee. Reflection, deserialization frameworks, unsafe language features, database corruption, or test helpers may bypass normal construction in some environments. Decide which boundaries are trusted and validate again when data re-enters from a source that does not preserve the invariant.
Revalidate when data crosses an untrusted persistence boundary
Writing a valid value to storage does not automatically mean every future read deserves trust.
A database may be modified by another application, an old software version, an administrative script, a migration, or manual repair. A message may have been produced before a rule changed. A cache may contain serialized data created by a different deployment.
If the storage boundary does not guarantee the invariant, treat loaded data as unchecked and reconstruct the trusted representation:
row = database.load(id)
quantity = parseQuantity(row.quantity)If the database schema itself enforces the same invariant and all writers respect it, the application may choose a lighter boundary. That is an engineering decision about what the system trusts, not a universal rule.
This is why “validate once” is too broad. A better rule is: validate when data enters a trust region whose code relies on stronger assumptions.
Use the technique where it removes meaningful uncertainty
Not every primitive value needs a wrapper.
Creating FirstName, LastName, StreetName, and CommentText types can add ceremony without removing important failure modes if those values have no distinct invariants or behaviors in the application.
The technique earns its cost when at least one of these conditions is true:
- the same validation is repeated in several places;
- invalid values cause meaningful failures downstream;
- functions rely on assumptions that are currently implicit;
- several similar primitive values are easy to mix up;
- a value has a clear invariant that can be established once and preserved.
For a small script or a value used in one local function, a direct conditional may be clearer. The goal is not to maximize the number of domain types. The goal is to reduce uncertainty at important boundaries.
Watch for validation drift
Once a representation owns an invariant, keep the rule and its construction path together.
A problematic design might validate quantities differently in several adapters:
web: 1 <= quantity <= 20
worker: 1 <= quantity <= 50
importer: quantity > 0All three then construct the same Quantity concept. The name suggests a guarantee that the system does not actually agree on.
Move the shared invariant behind one construction boundary and let adapters translate their input into it. If different workflows genuinely allow different ranges, model that distinction explicitly rather than hiding it behind one misleading representation.
This also makes rule changes easier. When the maximum changes from 20 to 30, developers can change the owner of that rule and update tests around the boundary instead of searching every caller for copied checks.
Treat trusted data as a reasoning tool
The main benefit of this design is not fewer if statements. It is a clearer answer to a question developers ask constantly:
What can I safely assume here?
Raw transport values imply few assumptions. A successfully constructed domain value can imply specific stable invariants. Context-dependent facts remain explicit checks because they may change.
A practical workflow is:
- Identify an assumption that downstream code repeatedly checks or silently relies on.
- Decide whether that assumption is stable for the lifetime of the value.
- Put the rule near the code that owns its meaning.
- Make successful validation construct a representation that preserves the rule.
- Accept that stronger representation in downstream code.
- Revalidate when data crosses a boundary that does not preserve the guarantee.
Used selectively, this turns validation from scattered defensive code into an explicit transition from unchecked input to data the rest of the program can reason about. That makes failures easier to locate, contracts easier to understand, and future changes less likely to leave inconsistent checks behind.