Module 3: Contract Testing Consumer And Provider

3. Consumer-driven contracts

Description

You already know what a contract is: a spec of behavior, not of shape. What remains is the question that decides how it's written: who rules the contract? When two components talk to each other, one uses the other. In Reservo, BookingService uses the BookingRepository; it calls save, get, find_by_room and trusts them to behave a certain way. The one who uses we call the consumer; the one who provides the service, the provider. This lesson's answer —and the heart of the whole discipline— is that the consumer rules. The contract's clauses aren't invented by the one who implements the repository; they're dictated by the real needs of the one who uses it. That's why they're called consumer-driven contracts: driven by the consumer.

The idea sounds backwards until you see it. The intuitive thing would be for the provider —the SqliteBookingRepository, which knows about tables and SQL— to define what it promises, and for the consumer to adapt. But that leads to contracts inflated with details nobody cares about (what the column is called, what index there is) and weak exactly where the consumer suffers (what happens when the booking doesn't exist). The consumer-driven twist is: the provider doesn't promise "everything I know how to do"; it promises exactly what its consumers need, no more and no less. BookingService.cancel calls repo.get(id) and, if the booking doesn't exist, needs that to raise so it can react; that concrete need is clause 2 of the contract. The contract is the portrait of the consumer's needs, and the provider signs to fulfill it.

Connection to the module: this lesson answers the who of the contract, after lesson 2 answered the what. And it sets the stage for what's coming: if the consumer defines the clauses, then the parametrized battery (lesson 4) is the way to demand each provider fulfill them, and catching module 2's divergence (lesson 5) is discovering a provider —the buggy fake— that doesn't fulfill a real need of the consumer. Here you're going to see, with real output, that the repository contract's clauses didn't come from nowhere: each one answers something BookingService really does.

Analogy: the client who specifies the order

Think of a carpentry shop that makes tables to order. There are two ways to agree on what table gets delivered. In the first, the carpenter decides: "I make oak tables, 1.80 by 0.90, with these turned legs; take it or leave it". The client, who needed a 1.20 table for a small room, adapts as best they can or leaves. The provider ruled, and the result serves only halfway. In the second, the client specifies: "I need 1.20 by 0.80, six chairs to fit, resistant to a spilled glass of water, and it has to pass through a 0.75 door". Those sentences —the real needs of whoever will use the table— become the contract. The carpenter is free to choose the wood, the type of joint, the finish; but they commit to fulfilling each of the client's requirements, and there's a test for each one (do six chairs fit? does it pass through the door?).

The second mode is consumer-driven. The client (consumer) doesn't tell the carpenter how to build —that's the provider's freedom—; they tell them what they need to observe in the result. The carpenter (provider) promises to fulfill those needs, and only those: they aren't required to make the table float or survive a fire, because the client didn't ask for it. That's how this guide's contracts are. BookingService doesn't tell the repository how to save (dict? table? file?); it tells it what it needs to be able to rely on —that saving and reading returns the same booking, that asking for a missing one raises—. And the repository, be it the fake or the real one, promises to fulfill those clauses. The contract is the client's list of requirements, not the carpenter's catalog.

From the consumer's need to the contract's clause

Let's make the exercise explicit, because it's the essence of "consumer-driven": let's take what BookingService really does and see how each use turns into a clause. Look at the consumer:

# reservo/services.py — the CONSUMER (excerpt)
class BookingService:
    def book(self, room, member, start, end):
        # ...validates, charges...
        booking = Booking(id=f"bk-{room.id}-{start.isoformat()}", ...)
        self._repo.save(booking)              # (A) needs to SAVE
        # ...
        return booking

    def cancel(self, booking_id):
        booking = self._repo.get(booking_id)  # (B) needs to READ, and to fail if it doesn't exist
        refund = refund_cents(booking, booking.price_cents, self._clock.now())
        booking.status = "cancelled"
        self._repo.save(booking)              # (C) needs to UPDATE without duplicating
        # ...
        return refund

Each line that touches the repository is a need, and each need calls for a clause:

  • (A) book saves and expects to be able to retrieve the booking later. If book saves a booking but get doesn't return it the same, cancel would compute the refund wrong. → Clause 1: save-and-read returns the same booking.
  • (B) cancel reads, and if the id doesn't exist it needs to find out. cancel("bk-ghost") can't carry on as if nothing happened: if get returned None, the next line (refund_cents(booking, ...)) would blow up with a cryptic AttributeError, or worse, compute garbage. The consumer needs get to raise so it can react cleanly. → Clause 2: get of a missing id raises.
  • (C) cancel saves the same booking again (now cancelled). It's the same id that already existed; the consumer needs this to update the state, not to create a second ghost booking for the same room and time. → Clause 3: save the same id twice updates, doesn't duplicate.

Notice what doesn't appear. The consumer doesn't need to know whether the repository uses a dict or a table, nor what the price column is called, nor whether there's an index. None of that enters the contract, because the consumer doesn't depend on it. The contract is the exact portrait of what BookingService touches and relies on —the border between the two components seen from the side of the one who uses—.

Worked example: the consumer runs against any provider

The proof that a contract is consumer-driven is that the consumer itself works, without changing a line, against any provider that fulfills the contract. Let's write tests of the complete BookingService —charging, saving, canceling— and run them against the fake and against SQLite, parametrizing the repository. If the consumer behaves the same with both, it's because both fulfill what the consumer needs.

# tests/test_consumer_relies_on_contract.py
import sqlite3
from datetime import datetime

import pytest

from reservo.calendar import Calendar
from reservo.doubles import (FakeBookingRepository, FixedClock,
                             SpyEmailSender, StubPaymentGateway)
from reservo.models import Member, Room
from reservo.services import BookingService
from reservo.sqlite_repo import SqliteBookingRepository

FOCUS = Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
ANA = Member(id="m-ana", name="Ana", tier="pro")
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)      # Focus 3 h
NOW = datetime(2026, 3, 7, 9)        # 72 h before -> full refund


@pytest.fixture(params=["fake", "sqlite"])
def repo(request):
    if request.param == "fake":
        return FakeBookingRepository()
    return SqliteBookingRepository(sqlite3.connect(":memory:"))


def make_service(repo):
    return BookingService(Calendar(), FixedClock(NOW),
                          StubPaymentGateway(ok=True), SpyEmailSender(), repo)


# The consumer relies on clause 1 (save-and-read): book -> cancel.
def test_consumer_can_book_then_cancel_against_any_provider(repo):
    service = make_service(repo)
    booking = service.book(FOCUS, ANA, START, END)
    refund = service.cancel(booking.id)
    assert refund == 6000                       # 72 h before -> full


# The consumer relies on clause 2 (missing get raises): cancel of a ghost.
def test_consumer_cancel_of_a_missing_booking_raises(repo):
    service = make_service(repo)
    with pytest.raises(KeyError):
        service.cancel("bk-does-not-exist")

Read it with the lesson's lens: test_consumer_can_book_then_cancel_against_any_provider exercises clauses 1 and 3 from within the consumer —book saves, cancel reads that same booking and saves it back cancelled—; test_consumer_cancel_of_a_missing_booking_raises exercises clause 2 —cancel of a nonexistent id, which must propagate the get's error—. And it all runs against the fake and against SQLite thanks to the parametrized fixture.

What to expect. On my machine (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_consumer_relies_on_contract.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 4 items

tests/test_consumer_relies_on_contract.py::test_consumer_can_book_then_cancel_against_any_provider[fake] PASSED [ 25%]
tests/test_consumer_relies_on_contract.py::test_consumer_can_book_then_cancel_against_any_provider[sqlite] PASSED [ 50%]
tests/test_consumer_relies_on_contract.py::test_consumer_cancel_of_a_missing_booking_raises[fake] PASSED [ 75%]
tests/test_consumer_relies_on_contract.py::test_consumer_cancel_of_a_missing_booking_raises[sqlite] PASSED [100%]

============================== 4 passed in 0.01s ==============================

Four greens, two per provider. The consumer behaves identically with the fake and with SQLite: it books, cancels with a refund of 6000 cents, and propagates the error when canceling a ghost. That equality isn't coincidence or luck: it's the signal that both providers fulfill the clauses the consumer needs. And notice the key detail for lesson 5: the second test, ..._of_a_missing_booking_raises, passes for both because both providers raise on get of a missing id. If here we put in the buggy fake from module 2 —the one that returns None—, the cancel would carry on to the line refund_cents(None, ...) and blow up with a different error, not with the expected KeyError: the consumer would break with that provider. The contract is consumer-driven because clause 2 exists precisely so the consumer doesn't break; the fake that violates it is a provider that doesn't serve the consumer, and that's what the battery gives away.

Who is consumer and who is provider (and why it matters)

It's worth fixing the roles, because they're relative to each seam, not absolute. At the repository seam, BookingService is the consumer (it uses) and the BookingRepository is the provider (it provides). At the payments seam, BookingService is again the consumer and the PaymentGateway is the provider. The same component can be the consumer of one seam and the provider of another: BookingService is the consumer of the repository, but it would be a provider if a web layer above used it. The role is defined by the direction of the use: the one who calls is the consumer; the one who responds is the provider.

Why does it matter who's who? Because it decides whose the clauses come from. A consumer-driven contract is written by looking at what the consumer does and needs, not at what the provider can offer. This has a very concrete practical consequence: if a provider wants to remove or change a behavior, the contract tells it whether any consumer depends on it. If no clause covers that behavior, it's free to change it; if a clause covers it, changing it would break a consumer, and the battery will go red to warn before deploying. That's the approach's superpower, and module 4 squeezes it: the consumer-driven contract turns "does anyone use this?" from an anxious question into an answer a test gives.

There's a second, subtler consequence: the consumer-driven contract keeps the spec small and honest. Since only the real needs of real consumers enter, it doesn't accumulate clauses about behaviors nobody uses. A provider-defined contract tends to grow ("let's also promise this, just in case") and become a burden that slows every change. A consumer-defined one is exactly as big as the real use, no more. It's the difference between a client's requirements list and a whole catalog: the first tells you what to test; the second drowns you in promises nobody claimed.

Common mistakes

Letting the provider dictate the contract. What happens: the SqliteBookingRepository team writes the contract from what their implementation does today —"we promise find_by_room returns in rowid order", "we promise the ids are UUIDs"—. Why it happens: it's what they have at hand; they describe their code. How to detect it: if a clause mentions a detail no consumer uses (an order nobody cares about, an internal id format), the contract was dictated by the provider. How to fix it: ask yourself "which consumer would break if this weren't fulfilled?". If the answer is "none", the clause is superfluous. The contract is the consumer's needs, not the provider's inventory.

Putting provider implementation details in the contract. What happens: a clause asserts about how the provider saves —"after save, there's a row in the bookings table"— instead of about what the consumer observes. Why it happens: it's tempting to verify what's easy to see from inside the provider. How to detect it: if the clause only makes sense for one implementation (SQLite's has a table; the fake doesn't), it's not a shared contract —the fake couldn't fulfill it even if it wanted to—. How to fix it: write the clause in terms any provider can fulfill, using only the public interface (get, find_by_room). "After save(b), get(b.id) returns b" is fulfillable by the dict and by the table; "there's a row" isn't.

Confusing the role at a seam with a fixed label on the component. What happens: someone says "BookingService is the consumer" flat out and gets confused when a layer that uses BookingService appears. Why it happens: the role is taken as a property of the object, not of the relationship. How to detect it: if you can't say "consumer of what seam", you're missing half the sentence. How to fix it: the roles are per seam. BookingService is the consumer of the repository and of the gateway, and it would be the provider of a higher layer. Always name the seam; the role lives in the relationship, not in the component.

Exercises

Exercise 1 — From the need to the clause. BookingService's book method, after validating and charging, does self._repo.save(booking) and then returns the booking. Later, a screen calls repo.find_by_room("focus") to list that room's bookings. Write the consumer's need behind find_by_room and the contract clause it calls for.

See solution

The consumer's need: the screen that lists a room needs find_by_room("focus") to return all and only that room's bookings —the ones book saved for "focus"—, without dragging in others' or omitting any. If find_by_room returned "studio" bookings mixed in, the screen would show other rooms' bookings; if it omitted one from "focus", it would hide real bookings.

The clause it calls for is 4: find_by_room returns only that room's bookings. Its test saves one booking in "focus" and another in "studio", asks for find_by_room("focus") and asserts that it returns exactly ["bk-1"] —the focus one, not the studio one—. The consumer's concrete need (listing a room correctly) became a concrete clause of the contract. That's the consumer-driven flow: each thing the consumer does with the provider turns into something the provider promises to fulfill.

Exercise 2 — A promise that's superfluous. The SqliteBookingRepository team proposes adding to the contract: "clause: save assigns each booking an incremental internal rowid, accessible via repo.last_rowid()". No BookingService method calls last_rowid(). Should this clause enter the consumer-driven contract? Justify.

See solution

It shouldn't enter. A consumer-driven contract only includes what some real consumer needs, and no consumer —neither BookingService nor the screen— calls last_rowid() or depends on any rowid. The clause is dictated by the provider (it talks about an internal detail of the SQLite implementation), not a consumer need. It's exactly the kind of promise that inflates the contract without protecting it from anything useful.

Besides, there's a practical problem that confirms it: the FakeBookingRepository (an in-memory dict) has no rowid or last_rowid(). If this clause entered the shared contract, the fake couldn't fulfill it —it would fail not because of a bug, but because the clause asks for something that only makes sense for one implementation—. That breaks the contract's premise: it must be fulfillable by any provider, expressed only in terms of the public interface the consumer uses. The clause stays out. If someday a real consumer needed an incremental identifier, then it would be added —driven by that need, not by what the provider has at hand—.

Exercise 3 — Break the contract from the provider. Imagine the infrastructure team changes the SqliteBookingRepository so that get of a missing id returns None "for consistency with another library" —not knowing that BookingService.cancel depends on it raising—. Explain: what would happen in production, and how the consumer-driven approach would have stopped it beforehand?

See solution

In production cancel would break. BookingService.cancel does booking = self._repo.get(booking_id) and, on the next line, refund_cents(booking, booking.price_cents, ...). If get returns None instead of raising, booking is None and booking.price_cents blows up with AttributeError: 'NoneType' object has no attribute 'price_cents' —a cryptic error, far from the cause, at production time—. Worse still: a change thought of as "cosmetic" in the provider broke a consumer the infrastructure team didn't even know existed.

The consumer-driven approach would have stopped it because cancel's need —"missing get must raise"— is already captured as clause 2 of the contract. As soon as the provider changed get to return None, the contract battery would run test_get_of_a_missing_id_raises[sqlite] and go red with DID NOT RAISE KeyError, on the machine of whoever made the change, before any deploy. The consumer-driven contract turns "does anyone depend on this raising?" —a question the infrastructure team didn't know to ask— into a test that answers on its own. That's the protection: the consumer's needs, written as clauses, watch over every provider change. It's exactly the case module 4 develops in detail.

Summary and next step

In this lesson you answered the who of the contract: the consumer rules. With the client who specifies the table you saw that the clauses come from the real needs of the one who uses the component, not from the catalog of the one who implements it. You went through BookingService line by line and saw how each use of the repository —save in book, read in cancel, update the cancelled status— turns into a concrete clause of the contract. And you checked it with real output: the consumer runs identically against the fake and against SQLite, four greens, because both providers fulfill what the consumer needs. You also fixed that the consumer/provider roles are per seam, not fixed labels, and that a consumer-driven contract stays small and honest —as big as the real use—.

Before moving on you should be able to: explain why the consumer rules and not the provider; translate a concrete use of the repository in BookingService into the clause it calls for; and rule out of the contract the promises only the provider wants (implementation details, behaviors nobody uses).

You now have the what (behavior) and the who (the consumer). What's missing is the how: the concrete mechanism to demand a single contract of several implementations at once. In lesson 4 we take apart pytest's parametrized fixture —the line @pytest.fixture(params=["fake", "sqlite"]) you've seen go by— and you understand exactly how it makes each clause run against the fake and against SQLite, and why that mechanic is what guarantees the fake can't lie.

Resources