Software Architecture · Modularity

Interface Contracts and the Limits of Substitutability

What a module boundary promises beyond its signature, why behavioural subtyping is stricter than type compatibility, and which contract clauses callers silently depend on.

A type signature is a claim about shapes: this function accepts two integers and returns a string. A contract is a claim about behaviour: the integers must be non-negative, the string is never null, it contains no leading whitespace, and the call has no effect on the receiver. Two implementations that share a signature may satisfy entirely different contracts, and a caller written against the weaker one will compile happily and fail at runtime. Module boundaries are only as strong as the behavioural clauses attached to them, which is why substitutability is a semantic property rather than a syntactic one.

The practical consequence is that the effective contract of an interface is the intersection of everything its implementations do, not the intersection of what its documentation says. If one implementation returns results in insertion order and another in hash order, callers who observed the first will depend on ordering whether or not anyone promised it. Documentation does not create the contract; observed behaviour does, and it is expensive to retract.

The four clauses of a module contract

Meyer’s formulation is the clearest vocabulary available. A routine’s contract has a precondition, which the caller must establish; a postcondition, which the routine must establish on return; and a class invariant, which holds on entry and exit of every public operation. To these, module boundaries add a frame condition: which parts of the state the operation may modify.

The clauses are asymmetric on purpose. A precondition is an obligation on the caller, and the routine is allowed to do anything at all — return garbage, throw, loop forever — when it is violated. A postcondition is an obligation on the implementation, and the caller may rely on it unconditionally. An interface that fails to state which is which forces callers to guess, and guessing tends toward treating every precondition as satisfiable and every postcondition as guaranteed.

For a module rather than a single function, the contract also covers the order in which operations may be called. A connection object may require open before read, permit read after close only to return an error, and forbid concurrent write. None of this appears in the method signatures. It is discovered by reading the implementation, by reading tests, or by production failure.

class Closed(Exception):
    pass


class Buffer:
    """Contract:
    - write(data) requires not closed; appends data; never reorders.
    - read(n) requires n >= 0 and not closed; returns min(n, available)
      bytes in FIFO order and consumes them.
    - close() is idempotent; after close, write and read raise Closed.
    """

    def __init__(self):
        self._chunks = []
        self._closed = False

    def write(self, data: bytes) -> None:
        if self._closed:
            raise Closed("buffer is closed")
        if data:
            self._chunks.append(data)

    def read(self, n: int) -> bytes:
        if self._closed:
            raise Closed("buffer is closed")
        if n < 0:
            raise ValueError("n must be non-negative")
        data = b"".join(self._chunks)
        out, rest = data[:n], data[n:]
        self._chunks = [rest] if rest else []
        return out

    def close(self) -> None:
        self._closed = True

The docstring is doing work the type system cannot: it fixes the ordering guarantee, the partial-read behaviour, and the idempotence of close. Remove those sentences and the class is still type-correct, but every caller now makes an assumption that the next implementation may break.

Substitutability is stricter than compatibility

Liskov and Wing’s principle states that if S is a subtype of T, then objects of type S may be used wherever objects of type T are expected without altering the correctness of the program. Correctness here is defined by the properties T guarantees, which produces two rules that are easy to state and easy to violate:

  • A subtype may weaken preconditions, never strengthen them. It must accept every input the supertype accepted.
  • A subtype may strengthen postconditions, never weaken them. It must deliver everything the supertype promised, possibly more.

The rules follow from the direction of the obligation. Callers are written to the supertype’s preconditions and rely on its postconditions. A subtype that demands more rejects inputs callers were entitled to send; a subtype that promises less returns states callers were entitled to exclude.

Mutability makes the second rule harder than it looks. A method that promises to return an unmodifiable list and returns a mutable one has kept the signature but weakened the postcondition, because callers may now mutate state the supertype said was fixed. Aliasing clauses — whether a returned object shares structure with the receiver, whether arguments are retained — belong in the postcondition and frame condition respectively, and they are the clauses most often omitted.

Where substitutability fails in ordinary code

Optional operations. A collection interface may declare add but permit implementations to reject it, as the Java collections framework does with UnsupportedOperationException for unmodifiable views. This is a documented deviation from substitutability: a method exists on the interface but is not guaranteed to work. The framework’s own guidance is that callers must not assume mutability, which means the interface no longer tells a caller what it can do. The alternative — separate read-only and mutable interfaces — keeps the contract honest at the cost of more types.

Equality and hashing. The equals contract requires reflexivity, symmetry, transitivity, consistency, and a non-null comparison; hashCode must agree with equals. Subclasses that add state almost always break symmetry unless they restrict equality to the same concrete class, and frameworks keyed on hash codes then behave inconsistently. The failure is not a type error. It is a contract violation that only appears when two objects meet in a hash-based collection.

Comparators. A sort comparator must be a total order: antisymmetric, transitive, and consistent with equality. Floating-point NaN breaks the natural comparison relation, so a comparator built on < for doubles must decide explicitly where NaN ranks, because an inconsistent comparator leaves the sort’s result unspecified. The contract is not “returns a negative number sometimes”; it is a set of algebraic laws.

Checked exceptions and error vocabularies. A module that surfaces a broader failure than its contract declares — a different checked type, or an unchecked one that escapes — leaves callers facing conditions the interface never mentioned; Java forbids widening a declared checked exception in an override, but that rule protects the override site rather than a versioned or remote boundary. Replacing a broad exception with a narrower one is safe for callers but may hide failures they were counting on distinguishing. The exception hierarchy is part of the postcondition.

Nullability and absence. A method whose return type admits null and never returns it has a stronger postcondition than its signature. Callers who rely on the strength are coupled to the implementation, and the next implementation that returns null breaks them without a type error. Languages with non-nullable reference types move this clause into the signature, which is the main argument for them.

Fragile base classes. A subclass that overrides a method may depend on the base class calling it in a particular pattern. The base class’s self-calls are then part of its contract, usually undocumented, and changes to the base class break subclasses that were never recompiled. This is substitutability failing in the opposite direction: the supertype is not substitutable for the subtype’s assumptions. Composition avoids the problem by making the interaction explicit, which is why the advice to prefer it is about contracts rather than about inheritance as a mechanism.

Thin contracts and the freedom they buy

There is a real trade between how much an interface promises and how many implementations can satisfy it. An interface that guarantees ordering excludes hash-based implementations; one that guarantees constant-time access excludes linked structures; one that guarantees thread safety excludes implementations that need external locking. Each guarantee narrows the set of valid implementations, and a narrow set is a maintenance risk when the underlying platform changes.

The design move is to split the contract by guarantee rather than by convenience. A minimal interface promises only what every implementation can deliver: maybe “returns all elements, each exactly once” with no ordering. A refining interface adds ordering. Callers that need ordering depend on the refinement and are explicit about it; callers that do not need it remain compatible with everything. This is the interface-segregation idea expressed in contract terms, and it keeps the substitution relation meaningful at every level.

The opposite move — one interface with a rich contract and optional methods that throw — concentrates power in the type and disperses the truth into documentation. It is convenient for implementers who want to supply most of the behaviour and for callers who want one type name, and it is expensive for anyone trying to reason about what is safe to call.

Contracts do not survive abstraction boundaries unchanged

A contract is only checkable where both sides are visible. Across a process boundary, a serialization format, or a versioned API, the compiler stops verifying anything and the contract becomes a document plus a test suite. The clauses that used to be enforced by types — non-null, exhaustive enum, closed set of error codes — must be enforced by validation on receipt, which means the boundary code grows a layer whose only job is to restore the assumptions the contract promised.

That layer is the honest cost of a module boundary. Information hiding determines which facts stay inside; the contract determines which facts cross; and package encapsulation determines whether the language will help enforce the separation or whether it depends on discipline alone. A boundary with a precise contract can be moved, reimplemented, or replaced. A boundary with a signature and no contract can only be copied.

References

  1. A Behavioral Notion of Subtyping — B. H. Liskov and J. M. Wing, ACM Transactions on Programming Languages and Systems 16(6), 1994
  2. Object-Oriented Software Construction — Bertrand Meyer, Prentice Hall, 2nd edition, 1997
  3. The Java Language Specification, Java SE 17 — Oracle