Back to the map

Module 01 · Object thinking · Lesson 3 of 4

Interfaces and contracts

An interface is a promise, not just a list of methods. Preconditions, postconditions and invariants, where to validate, documenting failure and side effects, and contract tests that keep every implementation honest.

CoreMust-knowMid 12 min Updated Nov 2026

Why it matters

Two implementations of the same interface can both compile and still behave so differently that swapping one for the other breaks production: one returns null for "not found", the other throws; one retries internally, the other does not; one is safe to call twice, the other charges the card twice. The method signatures matched; the contracts did not. Designing interfaces as explicit contracts is what makes polymorphism and dependency injection safe rather than merely possible.

The idea

Design by contract describes each operation by three kinds of promise. The caller must meet the preconditions; the implementation then guarantees the postconditions; and the object maintains its invariants before and after every public call.

PartWho is responsibleExample for withdraw(amount)
PreconditionThe caller must ensure itamount is positive
PostconditionThe implementation guarantees it on successbalance decreased by exactly amount; a ledger entry exists
InvariantThe object keeps it true at all observable timesbalance is never negative
Failure behaviourPart of the contract, documentedThrows InsufficientFunds and changes nothing if balance is too low
Callerensures: amount > 0withdraw(amount)«Account»pre: amount > 0 · post: balance −= amount · inv: balance ≥ 0SavingsAccountCreditAccount
The caller meets the preconditions; the implementation delivers the postconditions and keeps the invariant. Every implementation of the interface must honour the same contract.

What belongs in the contract

Signatures describe shape; the contract describes behaviour. For every operation in an important interface, decide and document:

  1. 1

    Inputs that are invalid

    What happens on null, empty, negative or out-of-range input. Usually: reject immediately with a clear exception.
  2. 2

    Results and absence

    Does 'not found' return an empty optional, null, or throw? Pick one convention per codebase.
  3. 3

    Failure modes

    Which typed errors can occur, and is the object left unchanged when they do?
  4. 4

    Side effects and idempotency

    Does calling it twice do the work twice? Is there an idempotency key?
  5. 5

    Cost and blocking

    Is it a cheap in-memory call or a network round trip that may take seconds and time out? Is it thread-safe?

Where to validate

Validate preconditions at the boundary of the object that owns the rule, and fail fast: reject bad input at the first public method that sees it, with an exception that names the problem, rather than letting a bad value travel and fail later somewhere confusing. Inside a module, trusted internal calls need not re-check everything; at module and service boundaries, check everything. Encoding constraints in types (an Email value object that cannot be constructed from an invalid string, a PositiveAmount) moves validation to construction and removes the checks from every method.

Substitutability follows from contracts

An implementation may accept more than the contract requires (weaker preconditions) and promise more than it guarantees (stronger postconditions), but never less. A subtype that rejects inputs the interface accepts, or skips a promised side effect, breaks every caller written against the interface. This is the Liskov substitution principle, covered fully in the SOLID module, and it is why contracts must be written down: you cannot honour a promise nobody stated.

Contract tests

Write one test suite against the interface and run it against every implementation, including the fakes used in other tests. If the in-memory fake and the real database repository both pass the same suite (save then find returns an equal object; find on a missing id returns empty; save twice is an update, not a duplicate), you can trust tests that use the fake.

abstract class OrderRepositoryContract {
    protected abstract OrderRepository repository();

    @Test void findReturnsWhatWasSaved() {
        var order = Order.create(customerId, lines);
        repository().save(order);
        assertEquals(Optional.of(order), repository().findById(order.id()));
    }
    @Test void findMissingReturnsEmpty() {
        assertTrue(repository().findById(OrderId.random()).isEmpty());
    }
}
class InMemoryOrderRepositoryTest extends OrderRepositoryContract { ... }
class PostgresOrderRepositoryTest extends OrderRepositoryContract { ... }

Trade-offs

Explicit contracts take time to write and maintain, and runtime precondition checks cost a few comparisons. In return, implementations become interchangeable, fakes become trustworthy and bugs surface at the point of misuse. The effort should scale with the interface's reach: a public API or plugin interface deserves a full written contract and contract tests; a private helper needs little more than a clear name.

In an interview

Speak in contracts when you introduce an interface: "PaymentGateway.charge takes an amount greater than zero and an idempotency key; it returns a charge id or throws Declined or GatewayUnavailable, and a repeat call with the same key returns the original result without charging again." It shows you are designing behaviour that implementations must honour, not just method names.

Check yourself

A UserRepository interface has findByEmail. The SQL implementation returns null when no user exists; the in-memory test fake throws NotFoundException. Tests pass, production crashes with a NullPointerException. What was missing?

Interfaces and contracts · Backplane