Design by Contract & Invariants
Preconditions, postconditions, and class invariants as a design tool — assertions vs exceptions, defensive programming at boundaries, and the Null Object pattern vs Optional.
Design by Contract & Invariants
Design by Contract, formalized by Bertrand Meyer for Eiffel, treats a class's methods like a legal contract between caller and callee: the caller promises certain things are true before calling (preconditions), and in exchange the method promises certain things will be true afterward (postconditions). Throughout an object's entire lifetime, some facts never stop being true (invariants). Interviewers care about this because it's the mechanism behind "defensive programming done right" — code that fails loudly and immediately at the point of a broken assumption, instead of silently corrupting state three call-frames away.
1. Preconditions, Postconditions, and Invariants Defined
| Contract element | Who's responsible | Violated by | Example |
|---|---|---|---|
| Precondition | Caller | Passing invalid input | withdraw(amount) requires amount > 0 |
| Postcondition | Callee (the method itself) | A bug in the method's implementation | After withdraw, balance == oldBalance - amount |
| Invariant | The class | Any method leaving the object in a broken state | balance >= 0 is always true, for every Account instance, at every moment outside a method body |
public final class BankAccount {
private long balanceCents;
public BankAccount(long openingBalanceCents) {
// PRECONDITION check (caller's promise)
if (openingBalanceCents < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.balanceCents = openingBalanceCents;
assertInvariant();
}
public void withdraw(long amountCents) {
// PRECONDITION: caller must request a positive amount they can afford
if (amountCents <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive");
}
if (amountCents > balanceCents) {
throw new InsufficientFundsException(amountCents, balanceCents);
}
long before = balanceCents;
balanceCents -= amountCents;
// POSTCONDITION: this method's own promise about its effect
assert balanceCents == before - amountCents : "withdraw postcondition violated";
assertInvariant();
}
// INVARIANT: must hold before and after every public method call
private void assertInvariant() {
assert balanceCents >= 0 : "balance invariant violated: " + balanceCents;
}
}Notice preconditions are checked with throw, not assert. That distinction is the most commonly tested part of this topic — covered next.
2. Assertions vs Exceptions: Which Failure Mechanism for Which Contract Element
This is the part engineers get backwards most often. The rule: preconditions on public APIs are the caller's responsibility to get right, so violations should throw a catchable, documented exception. Postconditions and invariants are the method's own internal correctness check — a violation means the method itself has a bug, not that the caller misused it, so assert (which can be stripped in production via -da) is appropriate.
| Preconditions | Postconditions / Invariants | |
|---|---|---|
| Mechanism | throw a specific exception | assert, or throw AssertionError |
| Who's at fault | The caller | The method's own implementation |
| Present in production? | Always — it's part of the public contract | Often stripped (java -da disables assert by default) |
| Recoverable? | Yes — caller can catch and handle | No — indicates a bug, not a recoverable condition |
Java assertions are disabled by default at runtime unless you run with -ea. Never use assert for precondition checks that must always run in production — use it only for internal invariants where you accept that production builds may skip the check (because the check itself is redundant with correct code, not a safety net for bad input).
3. Defensive Programming: Validate at Boundaries, Trust Internally
Design by Contract implies a specific defensive-programming discipline: validate rigorously at system boundaries (public API entry points — controllers, public method parameters, deserialized input) and then trust your own internal code. Re-validating the same data at every internal layer is redundant defensive programming that adds noise without adding safety.
// Boundary: full validation — this is untrusted external input
@PostMapping("/accounts/{id}/withdraw")
public ResponseEntity<?> withdraw(@PathVariable String id, @RequestBody WithdrawRequest req) {
if (req.amountCents() <= 0) {
return ResponseEntity.badRequest().body("amount must be positive");
}
accountService.withdraw(new AccountId(id), req.amountCents());
return ResponseEntity.ok().build();
}
// Internal: trusts that the boundary already validated. No redundant re-checking
// of "is amountCents positive" three layers deep — that would be noise.
class AccountService {
void withdraw(AccountId id, long amountCents) {
Account account = repository.find(id);
account.withdraw(amountCents); // Account's own precondition check is the real safety net
}
}The Account.withdraw() precondition check from Section 1 is not redundant with the controller's check — the controller validates against user-facing rules (a friendly 400 response), while the domain object's precondition is the last line of defense guaranteeing the invariant no matter what path reaches it (including future callers who forget the controller-level check).
4. Null Object Pattern vs Optional
Both exist to make "absence of a value" an explicit, contract-safe concept instead of a silent null that crashes three calls later with a NullPointerException at a location far from the actual bug.
// Optional: forces the CALLER to explicitly decide how to handle absence
interface CustomerRepository {
Optional<Customer> findById(CustomerId id);
}
Optional<Customer> customer = repository.findById(id);
String name = customer.map(Customer::name).orElse("Unknown Customer"); // handled explicitly
// Null Object: the CALLEE returns a real, harmless instance —
// callers don't need to branch on absence at all, because "absence"
// behaves like a valid, inert case of the same interface.
interface Logger {
void log(String message);
}
class NoOpLogger implements Logger {
public void log(String message) { /* intentionally does nothing */ }
}
class Service {
private final Logger logger; // never null — defaults to NoOpLogger
Service(Logger logger) { this.logger = logger != null ? logger : new NoOpLogger(); }
void doWork() {
logger.log("working"); // no null-check needed anywhere, ever
}
}Optional<T> | Null Object | |
|---|---|---|
| Best for | A single value that may or may not exist, especially return types | An interface where "do nothing" / "no-op" is itself a valid behavior |
| Caller experience | Must explicitly unwrap (.map, .orElse, .isPresent()) | Calls methods normally — no branching needed at all |
| Common misuse | Storing Optional as a field, or using it for method parameters | Using it where absence genuinely needs different handling per caller |
Don't use Optional as a field type or a method parameter type — it was designed specifically as a return type to signal "this method might not have an answer." Using it elsewhere (fields, constructor args, collection elements) adds serialization and boxing overhead for no contract benefit; use @Nullable annotations or the Null Object pattern instead.
5. Immutability as a Contract Guarantee
An immutable object's invariant, once established in the constructor, cannot be violated later by any code path — because there is no code path that mutates state after construction. This is the strongest possible contract enforcement: not "we check the invariant after every method," but "there is no method that could break it."
public final class DateRange {
private final LocalDate start;
private final LocalDate end;
public DateRange(LocalDate start, LocalDate end) {
if (start.isAfter(end)) {
throw new IllegalArgumentException("start must not be after end");
}
this.start = start;
this.end = end;
// invariant (start <= end) is now guaranteed FOREVER —
// there is no setter that could ever violate it later.
}
public boolean overlaps(DateRange other) {
return !start.isAfter(other.end) && !other.start.isAfter(end);
}
}When you find yourself writing assertInvariant() calls at the top and bottom of every mutating method (as in Section 1's BankAccount), ask whether the object could instead be immutable — replacing "mutate and re-check" with "construct a new, valid instance" removes the entire class of invariant-violation bugs at compile time rather than catching them at runtime.
Interview Questions
- What is the difference between a precondition, a postcondition, and a class invariant?
- Why should precondition violations
throwwhile postcondition/invariant violations typically useassert? - Why are Java assertions unsafe to rely on for validating untrusted external input?
- What does "validate at boundaries, trust internally" mean, and why does re-validating at every internal layer add noise rather than safety?
- When would you choose
Optional<T>over the Null Object pattern, and vice versa? - Why is it considered bad practice to use
Optionalas a field type or method parameter? - How does immutability change invariant enforcement from a runtime check to a compile-time guarantee?