A function receives an Order and immediately checks whether its total is negative, its currency is missing, and its status is compatible with its payment state. Another function repeats some of those checks. A third forgets one. The codebase contains validation everywhere, yet invalid combinations still appear.

The deeper problem is not a shortage of if statements. The program allows states that its own rules say should never exist, then asks every consumer to defend itself against them.

A useful design goal is to make important invalid states hard to represent. Instead of passing loosely constrained data through the system and repeatedly asking whether it is valid, establish clear boundaries that turn untrusted input into trusted domain state. This article shows how to identify useful invariants, enforce them at construction and transition points, and avoid turning the idea into unnecessary type complexity.

Start with the invariant, not the data structure

An invariant is a condition that must remain true for an object or concept to be considered valid within a particular part of the program.

For a simplified money value, the invariant might be:

amount is an integer number of minor units
currency is one of the currencies this application supports

For an order, a rule might be:

an order marked "paid" has a recorded payment reference

These are different from temporary input requirements. A checkout form may accept an empty field while a user is typing. That does not mean the domain object representing a submitted order should accept the same incomplete state.

The first design question is therefore:

Once this value has entered this part of the system, what must callers be allowed to assume about it?

That question defines the useful invariant. The representation should support that guarantee where doing so is practical.

See the cost of representing impossible combinations

Consider a simplified order record:

Order:
  status: "pending" | "paid"
  paymentReference: string | null

This representation permits four combinations:

pending + no payment reference
pending + payment reference
paid    + no payment reference
paid    + payment reference

Suppose the business model considers only the first and fourth combinations meaningful. The data structure can still express the other two.

That mismatch pushes work onto consumers:

function sendReceipt(order):
  if order.status != "paid":
    return

  if order.paymentReference == null:
    reportCorruptOrder(order)
    return

  sendPaidReceipt(order.paymentReference)

The second check exists because the representation does not preserve the rule that paid orders have payment references.

One alternative is to represent the valid cases directly:

PaymentState =
  Pending
  Paid(paymentReference)

Now a paid state carries the information that makes it paid. There is no separate paid + null combination for ordinary application code to create.

The exact syntax varies by language. The engineering principle does not: when two pieces of data must agree, consider representing their valid combinations together rather than storing independent fields and relying on convention.

Validate at the boundary where trust changes

External input cannot be made valid by choosing a better internal type. Requests can be malformed. Files can contain old data. Messages can arrive with missing fields. Database rows may predate a newer invariant.

So validation does not disappear. It moves to a boundary where untrusted data becomes trusted state.

A useful flow is:

request data
    |
    v
parse and validate
    |
    +---- invalid ----> explicit error
    |
    v
trusted OrderCommand
    |
    v
application logic

Suppose an API receives:

{
  "quantity": 3
}

The transport layer may initially treat quantity as an arbitrary number because that is what arrived over the wire. Before business logic uses it, a boundary can establish the rule:

PositiveQuantity.create(rawQuantity)
  -> PositiveQuantity
  or ValidationError

After successful construction, code that accepts PositiveQuantity should not need to check quantity > 0 again. If callers can bypass the constructor and freely mutate the underlying value, however, the guarantee is only cosmetic.

The important change is not the class name. It is the trust contract: validation happens before the value enters code that relies on the invariant, and the representation prevents ordinary operations from breaking it afterward.

Protect transitions as well as construction

A valid object can become invalid if updates bypass its rules.

Imagine this order state:

Order:
  paymentState: Pending | Paid(reference)
  shippedAt: timestamp | null

Suppose an order may be shipped only after payment. A valid constructor does not protect that rule if any caller can later assign shippedAt directly.

Instead, expose a transition that checks the precondition:

order.markShipped(now)
  if paymentState is Pending:
    return CannotShipUnpaidOrder

  return updated order with shippedAt = now

This creates a useful distinction:

  • construction establishes invariants for a new value;
  • transitions preserve invariants while the value changes.

If all supported ways of creating and changing the object preserve the rules, consumers can reason from the resulting state instead of revalidating its history.

This is especially valuable for rules that connect several fields. A setter that changes one field independently may expose intermediate or permanent combinations that the domain does not permit.

Keep boundary errors different from impossible internal states

Not every invalid value means the same thing.

A customer submitting quantity = 0 is an expected input error. The application should reject it through its normal validation path.

A function receiving a supposedly trusted PositiveQuantity whose internal value is 0 indicates something different: the program’s invariant has been broken. Treating both cases as ordinary user validation can hide a defect in construction, deserialization, migration, or mutation code.

It helps to separate two questions:

Can external input be converted into a valid value?

and:

Can trusted application code violate the value's guarantee?

The first normally needs a recoverable error that callers can handle. The second should be structurally difficult and, if detected, visible as an invariant violation rather than silently normalized.

For example, changing a negative quantity to 1 automatically may keep the program running, but it also invents business data. Rejecting the value preserves the distinction between valid state and corrupted or invalid input.

Choose representations that remove meaningful ambiguity

Not every nullable field deserves a new type. Look for combinations that repeatedly force callers to interpret what several fields mean together.

Consider an asynchronous job represented as:

status: "running" | "succeeded" | "failed"
result: Result | null
error: ErrorInfo | null

Callers must infer which fields are meaningful for each status. The structure also permits combinations such as succeeded with an error or failed with a result.

A representation closer to the valid cases is:

JobState =
  Running
  Succeeded(result)
  Failed(error)

This design communicates more than field-level nullability. Each case carries the data relevant to that case.

The benefit is strongest when the distinction drives behavior in many places. Consumers can handle the meaningful cases directly rather than reconstructing the relationship between independent fields.

The same idea applies without algebraic data types. A codebase might use separate classes, private constructors plus factory functions, validated value objects, or a module that exposes only invariant-preserving operations. Choose the lightest mechanism your language and codebase make clear.

Do not confuse domain invariants with every business rule

Trying to encode every rule into the shape of data can make a design rigid.

Some rules depend on information that changes independently. For example:

"This customer may place this order today"

might depend on account status, current credit, inventory, regional policy, and time. Making Order construction prove all of those facts permanently would be misleading because they can change after the order value is created.

A useful invariant is usually one that is intrinsic to the value or to a controlled lifecycle transition. Examples include:

a date range has start <= end
an email destination has passed the application's parsing rules
a paid state contains its payment reference
a percentage value stays within the range required by its domain

Context-dependent decisions often belong in services or policies that can examine current information.

Ask whether the guarantee should remain true for the lifetime of the value. If the answer depends on changing external context, it may be a decision to evaluate rather than an invariant to encode.

Account for persistence and deserialization

A common failure mode is to enforce invariants in normal constructors but bypass them when loading stored data.

Many systems have more than one creation path:

HTTP request -> parser -> domain value
database row -> mapper -> domain value
message      -> decoder -> domain value
test fixture -> helper -> domain value

If one path can construct an invalid object, downstream code cannot safely rely on the guarantee.

There are two legitimate strategies when persisted data may violate a newer invariant.

The first is to validate on loading and reject or quarantine invalid records. This keeps the in-memory model trustworthy but requires an operational plan for bad data.

The second is to represent legacy or incomplete data explicitly until it is repaired. This can be useful during migrations, but consumers must then handle that state deliberately.

What is dangerous is silently bypassing validation while still presenting the object as fully valid. That makes the type promise stronger than the actual system behavior.

Avoid validation scattered across consumers

Repeated defensive validation can look cautious while weakening ownership.

Suppose five functions contain:

if quantity <= 0:
  return error

Adding a sixth consumer now creates another place that must remember the rule. If the rule later changes, the codebase has a synchronization problem.

Once a trusted boundary establishes PositiveQuantity, consumers should use that guarantee:

reserveInventory(quantity: PositiveQuantity)
calculateLineTotal(quantity: PositiveQuantity, price: Money)

This reduces duplicated policy knowledge and makes function requirements visible at their boundaries.

There are exceptions. A system may deliberately validate again at a security boundary, before writing to a constrained external system, or when receiving data from a component whose guarantees cannot be trusted. Revalidation is useful when trust actually changes. Repeating the same check everywhere inside one trusted region usually is not.

Know when a simpler check is enough

Stronger representations introduce costs: more types, conversion code, names, and sometimes awkward integration with frameworks or serialization libraries.

A local condition may be clearer when a value has one consumer, the rule is obvious, and invalid state cannot escape that small scope:

if pageSize < 1 or pageSize > 100:
  return InvalidPageSize

Creating a rich domain type for every integer would add ceremony without necessarily improving reasoning.

Consider a stronger representation when at least one of these pressures appears repeatedly:

  • several consumers repeat the same validity check;
  • multiple fields must agree to describe one meaningful state;
  • invalid combinations have caused defects;
  • a rule is easy to forget when adding new code;
  • a value crosses enough internal boundaries that its guarantee is worth naming;
  • updates must preserve relationships between fields.

The goal is not maximum type sophistication. The goal is to spend design effort where an explicit guarantee removes meaningful uncertainty.

Use guarantees to simplify downstream reasoning

A good invariant should buy something for the rest of the code.

If DateRange guarantees start <= end, duration calculations do not need to defend against reversed ranges. If Paid(reference) guarantees that a payment reference exists, receipt code does not need a null check after matching the paid case. If a transition guarantees that only paid orders can become shipped, reporting code can interpret shippedAt with greater confidence.

That is the practical test for the design:

What checks or ambiguous cases can downstream code legitimately stop handling because this boundary now guarantees them?

If the answer is “none,” the new abstraction may not be carrying useful responsibility.

Start with an invariant that matters, identify where untrusted data becomes trusted state, and ensure every supported construction and transition path preserves the rule. Use a representation that expresses meaningful valid cases, but keep contextual decisions outside the value when they depend on changing information.

Making invalid states hard to represent does not eliminate errors at system boundaries. It gives those errors a clear place to be handled and lets the rest of the program work with stronger, explicit assumptions.