A function can have a precise type signature and still leave its most important rules implicit. A transfer operation may accept two accounts and an amount, yet the signature does not tell you whether the amount may be zero, whether the source needs enough funds, or what must be true after a successful transfer.

When these rules remain scattered through comments, conditionals, and tests, callers have to reconstruct the contract themselves. That makes misuse easier and changes harder to reason about.

A useful design tool is to separate the contract into preconditions and postconditions. A precondition states what must be true before an operation is valid to perform. A postcondition states what the operation guarantees when it completes successfully.

This article shows how to use that distinction to design clearer operations, place checks at useful boundaries, and avoid turning contracts into redundant defensive code.

Think in obligations and guarantees

The simplest mental model is:

caller obligation -> operation -> successful-result guarantee
   precondition                    postcondition

Suppose a system exposes this operation:

transfer(source, destination, amount)

A reasonable contract might say:

Preconditions:
    source and destination are different accounts
    amount > 0
    source has enough available balance

Postconditions on success:
    source balance decreased by amount
    destination balance increased by amount

This description does more than list validation rules. It divides responsibility.

The caller must request a meaningful transfer. The operation, once it accepts that request and reports success, must produce the promised state change.

That distinction helps answer an important engineering question: whose mistake is a violated condition? A failed precondition means the operation cannot validly proceed with the supplied situation. A failed postcondition means the implementation did not deliver its own guarantee.

Start with the smallest useful contract

Consider a simpler operation that applies a percentage discount:

applyDiscount(price, percentage)

Without an explicit contract, several questions appear immediately. Can price be negative? Can percentage exceed 100? Is a zero percentage valid? Can the result become negative?

A contract makes the intended domain visible:

Preconditions:
    price >= 0
    0 <= percentage <= 100

Postconditions:
    result >= 0
    result <= price

For a simplified implementation:

applyDiscount(price, percentage):
    require price >= 0
    require 0 <= percentage <= 100

    result = price * (1 - percentage / 100)

    ensure result >= 0
    ensure result <= price
    return result

require and ensure are pseudocode here, not language-specific APIs. The example demonstrates the roles of the checks rather than prescribing syntax.

Notice that the postconditions describe properties of the result, not the implementation formula. A caller usually cares that a valid discount cannot increase the price or make it negative. It does not need to know which intermediate variables produced that result.

Preconditions define the valid operating domain

A precondition should express a fact that must already hold when the operation begins.

Useful preconditions often describe relationships that a type alone cannot express. A date range may require start <= end. A withdrawal may require a positive amount. A method that moves an item between containers may require the source and destination to be distinct.

This matters because checking every individual value is not enough. Many invalid states arise from combinations of otherwise valid values.

For example:

schedule(startTime, endTime)

Both arguments may be perfectly valid timestamps while this call is still invalid:

startTime = 14:00
endTime   = 13:00

The relevant precondition is relational:

startTime < endTime

Thinking in preconditions therefore encourages developers to ask not only “is each input valid?” but also “is this operation valid for these inputs together?”

Postconditions describe what success means

A return without an error is useful only if callers know what success promises.

Consider an operation that adds a member to a team:

addMember(team, user)

A weak description says, “Adds the user.” A more useful contract identifies observable guarantees:

Precondition:
    user is not already a member

Postconditions on success:
    user is a member of team
    existing members remain members

The second postcondition is important. An implementation that accidentally replaces the membership list with [user] would satisfy “the user is a member” while still being wrong.

Good postconditions capture the properties that distinguish a correct state transition from a merely plausible one.

They can also describe what does not change. These frame conditions are especially useful when an operation touches shared or complex state:

Postconditions on success:
    order.status == "cancelled"
    order.items are unchanged
    order.total is unchanged

Explicitly naming preserved state can expose unintended side effects during design and review.

Do not confuse preconditions with input parsing

Not every rejected input should be treated as a programming-contract violation.

Data arriving from users, files, network requests, or external services is untrusted by nature. Invalid values at those boundaries are expected operational cases. The software should parse and validate them and return an appropriate error.

A precondition becomes most useful after that boundary, when code is calling an operation whose valid domain is already understood.

For example, an HTTP endpoint might receive:

amount = "-20"

The endpoint should parse the text, reject an invalid business request, and produce the application’s normal error response. It should not crash because an external user violated an internal assumption.

Deeper in the application, however, a transfer operation may still state amount > 0 as a precondition. That documents the operation’s valid domain and can expose a bug if trusted internal code bypasses the boundary validation.

The same rule can therefore appear at different layers for different reasons: boundary validation handles expected bad input, while a contract states what an internal operation is designed to accept.

Put checks where they expose the right failure

Writing down a contract does not mean every condition must be checked everywhere.

A useful decision process is:

  1. State the condition clearly.
  2. Decide which component owns the condition.
  3. Check it where a violation can be detected reliably and explained usefully.

Suppose reserve(stock, quantity) requires quantity > 0. If quantity comes from an external request, the request boundary should reject non-positive values. The reservation operation may also assert the same precondition if internal misuse would otherwise be difficult to diagnose.

By contrast, repeatedly checking a condition in every helper function can add noise without improving safety. Once data has crossed a trusted boundary and the program structure preserves the condition, downstream code can often rely on it.

The goal is not maximum checking. The goal is a clear chain of responsibility.

Preconditions should not hide race conditions

Some conditions can change between checking them and acting on them.

Consider:

Precondition:
    source.balance >= amount

If another operation can modify the balance concurrently, this sequence is unsafe:

if source.balance >= amount:
    transfer(source, destination, amount)

The balance may change after the check but before the transfer commits.

The contract is still useful, but the implementation must enforce the condition at the point where the state change becomes authoritative. Depending on the system, that may require synchronization, a transactional operation, optimistic concurrency control, or another mechanism appropriate to the shared state.

A contract describes the required truth. It does not by itself provide atomicity.

This is an important boundary condition: never treat an earlier observation of mutable shared state as proof that the same condition still holds later.

Postconditions are especially useful in tests

Postconditions provide natural test properties because they describe externally meaningful guarantees.

For the discount example, tests can check boundary cases such as:

price = 100, percentage = 0   -> result = 100
price = 100, percentage = 100 -> result = 0

They can also check broader properties across many valid inputs:

0 <= result <= price

For state-changing operations, tests can verify both changed and preserved state. A cancellation test might assert that the status changed while line items and totals did not.

This makes tests less dependent on implementation details. If the algorithm changes but the contract remains the same, contract-focused tests can often remain unchanged.

Still, postconditions are not a complete test strategy. They describe selected guarantees, and a weak contract can omit important behavior. Tests should also cover error paths, integration boundaries, and other behavior that matters to the system.

Avoid contracts that merely repeat the code

A contract loses value when it describes implementation steps instead of meaningful obligations and guarantees.

Consider:

calculateTotal(items):
    subtotal = sum(items)
    tax = subtotal * rate
    return subtotal + tax

A postcondition such as “the function calculates subtotal, then calculates tax” does not help callers and makes refactoring harder. It restates the algorithm.

A better postcondition describes stable observable behavior, for example:

result equals the sum of item prices plus the applicable tax

Even that guarantee should be no more specific than the domain requires. If tax rounding rules matter, state them. If they do not belong to this abstraction, do not expose an accidental implementation detail as part of the contract.

The same principle applies to preconditions. “The caller must pass an array because the current loop expects one” is weaker than expressing the actual requirement, such as “the operation requires a finite collection of priced items.”

Do not strengthen or weaken contracts casually

Contracts become particularly important when one implementation is substituted for another behind the same interface.

Suppose callers know an operation accepts any percentage from 0 through 100. A replacement implementation that accepts only 0 through 50 imposes a stronger precondition. Existing valid callers can now fail.

Similarly, if the original operation guarantees that a successful result never exceeds the original price, a replacement that drops that guarantee gives callers less than they were promised.

The practical rule is simple: when changing an implementation behind an established abstraction, be cautious about requiring more from callers or guaranteeing less to them.

This does not mean contracts can never change. It means such a change is an interface change and should be treated deliberately rather than hidden inside an implementation refactor.

Use simpler designs when types can carry the rule

Runtime precondition checks are not the only way to express valid inputs.

If a rule can be represented naturally by a type, constructing that type once can make later code simpler. Instead of passing arbitrary numbers throughout the system, a validated Percentage value might guarantee that its value is between 0 and 100.

Then this operation:

applyDiscount(price, percentage)

can rely on part of its contract because percentage cannot represent an out-of-range value after construction.

This approach is useful when the concept appears repeatedly and the type genuinely represents domain knowledge. It is unnecessary when a condition is local, obvious, and used once. Creating a new type for every comparison can make a small codebase harder to navigate.

Contracts and types complement each other. Types can make some preconditions unrepresentable, while explicit conditions remain useful for relationships that depend on runtime state or multiple values.

Common mistakes

One mistake is documenting conditions without deciding what happens when they fail. For externally supplied data, failure usually belongs in normal error handling. For violated internal assumptions, an assertion or explicit failure may be more appropriate. The choice depends on whether the condition can reasonably fail during normal operation.

Another mistake is specifying only the happy-state change and ignoring preserved state. A postcondition that says “the new member exists” is incomplete if an implementation could satisfy it by deleting all previous members.

A third mistake is using preconditions to move validation responsibility arbitrarily to callers. A component should not demand that callers verify facts it alone can determine reliably. If an operation owns the authoritative state needed to decide whether a transition is allowed, that decision generally belongs inside the operation.

Finally, avoid contracts so detailed that every refactor becomes a contract change. Describe behavior at the abstraction’s boundary, not its internal sequence of steps.

When this mental model is most useful

Preconditions and postconditions are especially useful when an operation has rules that are easy to miss from its signature, changes important state, is called from many places, or sits behind an interface with multiple implementations.

They are less valuable for tiny private helpers whose valid inputs and results are already obvious from nearby code. In those cases, additional contract comments or checks may repeat information without improving understanding.

The test is practical: does stating the obligation and guarantee make a caller or maintainer less likely to misunderstand the operation? If yes, the contract is carrying useful design information.

Conclusion

Preconditions and postconditions turn vague assumptions into a clear division of responsibility. Preconditions define the state in which an operation is valid to call. Postconditions define what successful completion guarantees.

Use them to reason about relationships between inputs, state transitions, preserved state, and substitutable implementations. Validate untrusted data at system boundaries, enforce mutable-state conditions where they are authoritative, and avoid duplicating checks when the program structure already preserves the rule.

The practical habit is small: before implementing an important operation, ask what must already be true, and what will I promise if this succeeds? Those two answers often reveal design problems before they become debugging problems.