Low-level designCore9 min

Design by contract

Say what each method needs from its caller, what it promises in return, and what stays true of the object throughout. Then check it, so a bug stops at the call that broke the rules instead of surfacing much later.

Watch it evolve1 / 9
The problem

A ferry tracks its car deck

CarDeck keeps the used length of a 20 m lane. Callers ask fits(), then board() or leave().

80
Start
Stage 1
Final

1 · The situation

A ferry's car deck

A small island ferry has one car lane, 20 meters long. CarDeck tracks how much of it is used: the booking system asks fits(length) before a car drives on, calls board(length) when it does, and leave(length) when a booking is canceled. Lengths are whole centimeters, so a 4.5 meter car is 450.

The first versionJava
class CarDeck {
    private final int capacityCm;
    private int usedCm;

    CarDeck(int capacityCm) {
        this.capacityCm = capacityCm;
    }

    boolean fits(int lengthCm) {
        return usedCm + lengthCm <= capacityCm;
    }

    void board(int lengthCm) {
        usedCm += lengthCm;
    }

    void leave(int lengthCm) {
        usedCm -= lengthCm;
    }

    int usedCm() {
        return usedCm;
    }
}

public class Main {
    static String meters(int cm) {
        String sign = cm < 0 ? "-" : "";
        int c = Math.abs(cm);
        return String.format("%s%d.%02d m", sign, c / 100, c % 100);
    }

    static void board(CarDeck deck, int lengthCm) {
        if (!deck.fits(lengthCm)) {
            System.out.println("board " + lengthCm + ": no room");
            return;
        }
        deck.board(lengthCm);
        System.out.println("board " + lengthCm + ": used " + meters(deck.usedCm()));
    }

    static void leave(CarDeck deck, int lengthCm) {
        deck.leave(lengthCm);
        System.out.println("leave " + lengthCm + ": used " + meters(deck.usedCm()));
    }

    public static void main(String[] args) {
        CarDeck deck = new CarDeck(2000);
        board(deck, 450);
        board(deck, 480);
        leave(deck, 480);
        // The booking system retries the cancellation after a timeout.
        leave(deck, 480);
        for (int length : new int[] {520, 510, 500, 470}) board(deck, length);
    }
}
CarDeck− capacityCm: int− usedCm: int~ fits(int): boolean~ board(int)~ leave(int)~ usedCm(): int

The class diagram of this version, drawn from its code: one class, and nothing about what its methods expect.

The program boards two cars and cancels the second. Then something goes wrong in the booking system: the cancellation times out and is retried, so the same car leaves twice. Four more cars arrive:

board 450: used 4.50 m
board 480: used 9.30 m
leave 480: used 4.50 m
leave 480: used -0.30 m
board 520: used 4.90 m
board 510: used 10.00 m
board 500: used 15.00 m
board 470: used 19.70 m

The deck reports 19.70 m used. Five cars are aboard, 24.50 m of them.

2 · The change

A bug that surfaces four cars later

The used length went to -0.30 m, which can't be true of any deck, and nothing noticed. The deck then let on four cars as if the lane had 4.80 m more room than it does, and the last of them fit only on paper: 24.50 m of cars on a 20 m lane. The bug happened at the second leave(480); it shows up on the car deck, four cars later, as a car that can't drive on.

Unchecked
4
Checked
0

Calls between the bad leave(480) and the moment anyone could notice. Unchecked: the four boardings that follow, and then the ramp. Checked: none; the bad call itself fails.

Nothing guards the arithmeticJava
class CarDeck {
    private final int capacityCm;
    private int usedCm;

    CarDeck(int capacityCm) {
        this.capacityCm = capacityCm;
    }

    boolean fits(int lengthCm) {
        return usedCm + lengthCm <= capacityCm;
    }

    void board(int lengthCm) {
        usedCm += lengthCm;
    }

    void leave(int lengthCm) {
        usedCm -= lengthCm;
    }

    int usedCm() {
        return usedCm;
    }
}

CarDeck never said what it expects. Its code assumes several things that nobody wrote down:

Unwritten assumption

Who broke it here

What happened

A car leaving was aboard

The retried cancellation

The deck subtracted a car that wasn't there

Lengths are positive

Nobody, yet

A negative length would board a car and free up room

board() follows a fits() that said yes

Nobody, yet

A caller that skips the check overloads the deck

Used length stays between 0 and the capacity

The two above, together

The deck's own numbers stopped being true

The practice checks can't help here: they read a design's structure, and this is about the values passing through it at run time.

3 · The fix

Write the contract down, and check it

Start from the first version, turn each assumption into a check, and fail at once when one is broken:

board() requires a positive length that fits; leave() requires a positive length no larger than what's aboard. A small require() throws when one isn't met, with a message that names the call.

Java
class CarDeck {
    private final int capacityCm;
    private int usedCm;

    CarDeck(int capacityCm) {
        require(capacityCm > 0, "a deck needs a positive capacity");
        this.capacityCm = capacityCm;
    }

    boolean fits(int lengthCm) {
        return usedCm + lengthCm <= capacityCm;
    }

    void board(int lengthCm) {
        require(lengthCm > 0, "board(" + lengthCm + ") needs a positive length");
        require(fits(lengthCm), "board(" + lengthCm + ") needs room; check fits() first");
        usedCm += lengthCm;
        checkInvariant();
    }

    void leave(int lengthCm) {
        String call = "leave(" + lengthCm + ")";
        require(lengthCm > 0, call + " needs a positive length");
        require(lengthCm <= usedCm,
                call + " needs " + lengthCm + " cm aboard, only " + usedCm + " cm used");
        usedCm -= lengthCm;
        checkInvariant();
    }

    int usedCm() {
        return usedCm;
    }

    private void checkInvariant() {
        if (usedCm < 0 || usedCm > capacityCm) {
            throw new IllegalStateException("used length out of range: " + usedCm + " cm");
        }
    }

    private static void require(boolean condition, String message) {
        if (!condition) throw new IllegalArgumentException(message);
    }
}
CarDeck− capacityCm: int− usedCm: int~ fits(int): boolean~ board(int)~ leave(int)~ usedCm(): int− checkInvariant()− require(boolean, String)

Now the retried cancellation fails at the call that's wrong, with a message that says why, and the deck's numbers stay true. The fourth car of the second batch is turned away, because the deck really is full:

board 450: used 4.50 m
board 480: used 9.30 m
leave 480: used 4.50 m
leave 480: refused, leave(480) needs 480 cm aboard, only 450 cm used
board 520: used 9.70 m
board 510: used 14.80 m
board 500: used 19.80 m
board 470: no room

The retried call checks its preconditions before it changes anything, and fails with a reason. The deck's state is untouched.

The deck didn't fix the booking system's bug; it made it impossible to miss. The real fix is in the caller: a retried cancellation should find the car already gone and do nothing.

4 · The principle

Preconditions, postconditions and invariants

Design by contract treats each method as an agreement between a caller and the class. The caller must meet the method's preconditions; in return, the method guarantees its postconditions; and throughout, the class keeps its invariants, the facts that are true of every object between calls. A broken contract is a bug, and it says whose bug: a failed precondition is the caller's, a failed postcondition or invariant is the class's.

Part

Who must keep it

For CarDeck

Precondition

The caller, before the call

leave(n): n is positive and at most what's aboard

Postcondition

The method, when it returns

After board(n), the used length is n more than before

Invariant

The class, between every two calls

The used length is between 0 and the capacity

The contract simplifies both sides. The caller knows exactly what it must do and what it gets back; the method doesn't need code for situations its preconditions rule out. That's the difference from defensive programming, where a method quietly copes with anything, such as ignoring a negative length: coping hides the caller's bug, and a contract exposes it.

Contracts also explain Liskov substitution precisely: a subclass may accept more than its parent (weaker preconditions) and promise more (stronger postconditions and invariants), but never the reverse.

5 · When breaking it is fine

Checks cost time, and some belong elsewhere

Checks aren't free:

  • Run time. An invariant that walks a large structure after every call can cost more than the work itself. That's why several languages let you turn assertions off.
  • Duplicate checks. If every layer checks the same value, the rule lives in several places and can drift.
  • Noise. A method that is mostly checks hides what it does.

Leave a condition unchecked, but still written down, when checking it is expensive and the code is hot, when it's already guaranteed by a type (a value object that can't hold a negative length needs no check), or inside a private helper whose only callers are in the same class and already checked it. Disabling checks in production is a trade: faster code, and bugs that surface later, the way the first version's did.

Check

Keep it on in production?

Why

A public method's precondition

Yes

It's part of the method's published contract, and cheap

A cheap invariant

Usually

It catches the class's own bugs where they happen

An expensive invariant or postcondition

Often not

Run it in tests and debug builds instead

6 · Getting it right

Validate input at the boundary; check contracts inside

Two kinds of bad value look alike and need different handling:

  • Bad input from outside, such as a length typed into a booking form as "4,5", is expected. Validate it where it enters the program, and tell the user what to fix.
  • A broken contract inside, such as the retried cancellation, is a bug. Fail fast, loudly, with a message for the programmer.

Once input is validated at the boundary, the code inside can rely on its preconditions instead of checking again at every layer.

Language

Preconditions on public methods

Assertions for internal checks

Java

Throw IllegalArgumentException or IllegalStateException; libraries such as Guava's Preconditions wrap this

assert, which is off unless the program runs with -ea

C++

Throw std::invalid_argument; C++26 adds pre and post on declarations

assert, removed when NDEBUG is defined; C++26 adds contract_assert

JavaScript

Throw an Error such as RangeError

No built-in: console.assert only logs and carries on

Python

Raise ValueError or TypeError

assert, which emits no code when Python runs with -O

Because assertions can be switched off, never use them for a public method's preconditions or for anything with a side effect: the check, and the effect, would vanish in an optimized build.

7 · Look-alikes

Contracts and their neighbors

Several ideas deal with bad values. They differ in whose problem the value is.

Idea

The question it answers

Design by contract

What does each method require, ensure and keep true, and whose bug is a violation?

Defensive programming

How does a method cope with anything it's given? It can hide the caller's bug

Input validation

Is outside data acceptable? Expected failures, reported to the user

Errors and exceptions

How does a failure travel to code that can handle it?

Value objects

Can a type make the bad value impossible to construct, so no check is needed later?

Liskov substitution

How may a subclass change its parent's contract?

The strongest contract is one the type system enforces. A Length value object that refuses negative numbers in its constructor turns "lengths are positive" from a check in every method into a fact about every Length; see Value objects, equality and immutability.

8 · In practice

Contracts in the libraries you already use

Standard libraries publish contracts all the time. A list's get(index) requires an index inside the list and throws an index exception when it isn't; Java's Objects.requireNonNull states a precondition in one call; Guava's Preconditions offers checkArgument for arguments and checkState for the object's state.

In the practice problems, the same thinking applies to the vending machine's coins and selections and the parking lot's spots: decide which bad requests are a customer's mistake to report, and which are a programming error to stop at once.

Check yourself

4 questions

1. leave(480) is called when only 450 cm are aboard. Whose bug is it?
2. Why is clamping the used length at zero, instead of failing, a poor fix?
3. A booking form receives "4,5" as a car's length. How should the program treat it?
4. Why shouldn't a public method's precondition be checked with Java's assert?

Takeaways

Remember this

  • A method's contract has three parts: what it requires, what it ensures, and what stays true of its object.
  • A failed precondition is the caller's bug; a failed postcondition or invariant is the class's.
  • Check contracts and fail at once, so a bug stops where it happens instead of surfacing later.
  • Validate outside input at the boundary; check contracts inside for programming errors.
  • Assertions can be switched off, so public preconditions should throw ordinary exceptions.