An API can have clear parameter names and still leave its most important rules unstated. Can a withdrawal amount be zero? Must an account already be open? If a call succeeds, is the balance guaranteed to have changed, or has the request merely been accepted for later processing?
When those questions are unclear, callers make assumptions. Different callers may make different assumptions, and failures appear far from the decision that caused them.
Preconditions and postconditions provide a simple mental model for making these responsibilities explicit. A precondition states what must be true before an operation is valid. A postcondition states what the operation guarantees when it completes successfully.
This article shows how to use that model in everyday API design, where validation belongs, how failures affect the contract, and when a formal-looking contract would add more ceremony than value.
Think in obligations and guarantees
Consider a simplified account operation:
account.withdraw(amount)The method name tells us what the operation intends to do, but not the conditions under which it is valid. Suppose the intended rules are:
amountmust be greater than zero;- the account must be open;
- the account must have enough available funds;
- after a successful call, the available balance is lower by exactly
amount.
The first three statements are preconditions. They describe the state required before the operation can validly produce its normal result. The last statement is a postcondition. It describes a fact callers may rely on after success.
A compact way to reason about an operation is:
if preconditions hold
and the operation succeeds
then postconditions holdThat sentence is more useful than treating validation as a collection of unrelated if statements. It separates what the caller must provide from what the implementation promises in return.
Start with one small contract
Imagine a collection with a removeAt(index) operation. Its smallest useful contract might be:
precondition:
0 <= index < size before the call
postconditions after success:
size after the call = size before the call - 1
the returned value is the element previously at indexThe precondition explains which inputs are meaningful. The postconditions explain what success means.
Notice what the contract does not say. It does not prescribe an array, linked list, tree, or any other internal representation. A good contract constrains externally observable behavior while leaving implementation choices open when callers do not need to know them.
This distinction matters for maintainability. If documentation promises an implementation detail, changing that detail can become an API change even when the useful behavior remains identical.
Decide who is responsible for each precondition
Writing a precondition raises an immediate question: must the method check it, or may it assume callers already have?
There is no single answer for every API. The important part is to make the responsibility deliberate.
For a public boundary that receives untrusted input, checking is usually necessary. An HTTP endpoint that accepts a quantity cannot safely assume every client sends a positive integer. It should reject malformed or invalid input according to the endpoint’s documented error behavior.
Inside a small module, repeated checks may be unnecessary when an earlier boundary already establishes the invariant. For example:
quantity = Quantity.create(request.quantity)
order.add(quantity)If Quantity.create is the only way to obtain a Quantity and it rejects non-positive values, order.add does not need to re-check that the underlying number is positive merely for defensive appearance.
The useful question is not “Should every method validate everything?” It is:
At which boundary does this condition become trustworthy, and can later code rely on that guarantee?
Duplicating checks everywhere can obscure responsibility. Omitting them everywhere leaves the assumption unprotected. A clear trust boundary avoids both extremes.
Separate caller errors from operation failures
A precondition describes whether a request is valid to attempt. It does not guarantee that every valid attempt will succeed.
Suppose a payment operation requires a positive amount and a supported currency:
paymentGateway.charge(amount, currency)Those can be preconditions. Even when both hold, the network may time out or the payment provider may decline the charge. Those outcomes are not necessarily precondition violations; they are part of the operation’s failure model.
This distinction improves API design because callers need different responses to different failures. Invalid input may require correcting the request. A timeout may justify a carefully designed retry. A decline may be a valid business outcome that should be shown to the user.
Do not hide all three behind a vague “operation failed” result if callers need to react differently.
State postconditions for each meaningful outcome
A postcondition should be tied to an outcome. Saying “the balance decreases after withdraw” is incomplete if the operation can fail.
A clearer contract is:
on success:
availableBalance decreases by amount
a withdrawal record is created
on insufficient funds:
availableBalance is unchanged
no withdrawal record is createdNow the failure path has useful guarantees too.
This is especially important when an operation changes state. Callers often need to know whether a failed operation left partial work behind. If the implementation cannot guarantee all-or-nothing behavior, the contract should not imply it.
For example, an operation that sends an email and then records an audit entry may successfully send the email before the audit write fails. Unless there is a mechanism that changes those semantics, the API must not claim that failure means “nothing happened.”
The postcondition should describe the behavior the system can actually guarantee.
Use invariants to connect operations
A related concept is an invariant: a condition that must remain true for every valid state of an object or module.
Suppose an inventory item has this invariant:
reserved >= 0
available >= 0An operation such as reserve(quantity) has to preserve that invariant. Its contract might be:
precondition:
quantity > 0
quantity <= available
postconditions after success:
reserved increases by quantity
available decreases by quantityIf the invariant held before the operation and the implementation satisfies these postconditions, the invariant still holds afterward.
This gives developers a useful way to review state-changing code: identify the state rules that must always remain true, then check whether each operation’s preconditions and state transitions preserve them.
Invariants should represent rules intrinsic to the component. A temporary campaign limit or a user-specific permission may belong to a wider policy rather than to the inventory object’s permanent invariant.
Make the contract visible in the API shape
Contracts are strongest when the API itself communicates them instead of relying entirely on prose.
Types can make some invalid calls impossible or harder to express:
reserve(quantity: PositiveQuantity)A result type can make failure outcomes explicit:
reserve(quantity) -> Reserved | InsufficientStockA state-specific interface can expose only operations valid in a particular phase. Assertions can expose broken internal assumptions during development and testing.
These techniques do not eliminate the need for documentation. They move part of the contract into structures that tools and callers can see directly.
Use the lightest mechanism that fits the risk. A private helper with an obvious integer range may need only a clear name and a local assertion. A public API used by independent clients usually needs explicit validation, documented error behavior, and stable observable guarantees.
Be precise about time and concurrency
Postconditions become subtle when other actors can change the same state concurrently.
Suppose reserve(5) succeeds. A postcondition such as “available stock is 15 after the call” may be true at the instant the operation commits, but another reservation could immediately reduce the stock again.
The stronger statement “any read performed later will return 15” would therefore be false in a concurrent system.
Prefer a guarantee scoped to the operation:
on successful commit:
this reservation reduces available stock by 5If the API provides stronger isolation or synchronization guarantees, state them explicitly. Otherwise, do not turn a momentary state transition into a promise about future observations.
The same care applies to asynchronous APIs. If submitReport() only enqueues work, its successful postcondition may be “the report request was accepted for processing,” not “the report exists.” Naming and documentation should preserve that distinction.
Avoid contracts that expose implementation details
A contract can become too strong.
Imagine documenting a cache operation like this:
postcondition:
the item is stored in the first slot of the internal arrayA caller normally has no reason to depend on that fact. Promising it prevents harmless implementation changes.
A more useful postcondition might be:
postcondition:
a subsequent lookup by the same key can return the stored value
subject to the documented eviction policyThe second statement describes behavior relevant to callers and acknowledges an important boundary condition.
Every guarantee has a maintenance cost. Promise what consumers need to build correct software, not every fact that happens to be true in the current implementation.
Common mistakes
Treating every validation rule as a precondition
Some rules describe normal business outcomes rather than invalid calls. Asking to reserve five items when only three remain may be modeled as an expected InsufficientStock result instead of a programmer error. Choose based on how callers are expected to use the API.
Documenting success but not failure
State-changing operations need clear failure semantics. If callers do not know whether partial effects are possible, safe recovery becomes difficult.
Checking a condition after using it
A precondition must be established before code relies on it. Validating an index after reading items[index] cannot protect the read that already occurred.
Making guarantees the implementation cannot enforce
A comment that promises atomicity, ordering, durability, or immediate visibility does not create those properties. Such guarantees require implementation mechanisms that actually provide them.
Repeating checks without establishing ownership
If five layers all validate the same condition differently, the codebase has not gained five times the safety. It has gained multiple definitions of validity. Establish one authoritative boundary where possible and let later code rely on the resulting invariant.
When a simpler approach is enough
Not every function needs a formal contract section. For small internal code, descriptive names, types, and a few focused tests may communicate the behavior sufficiently.
The precondition/postcondition model is most useful when an operation changes important state, has non-obvious valid inputs, crosses a module or team boundary, exposes multiple failure outcomes, or is difficult to reason about because callers and implementations disagree about responsibility.
You can use the mental model without introducing special syntax or a contract framework. Ask what must be true before the call, what each outcome guarantees afterward, and which component is responsible for establishing each condition. Often those three questions reveal the design problem directly.
Conclusion
Preconditions and postconditions turn an API from a command name into an explicit exchange of responsibilities. The caller establishes the conditions required for a valid attempt; the implementation provides defined guarantees for each outcome.
Use that model to place validation at deliberate trust boundaries, distinguish invalid requests from operational failures, preserve invariants during state changes, and avoid promises that exceed what the system can enforce. Keep contracts focused on observable behavior rather than internal representation.
The practical result is not more documentation for its own sake. It is fewer hidden assumptions: callers know what they must provide, implementations know what they must preserve, and failures have a clearer place in the design.