Module 8: Project Contract And Integration For Reservo

2. Write the repository's consumer-driven contract

Description

The construction of the delivery begins, and the first deliverable is the contract. In this lesson you write it with your own hands, from start to finish: the four clauses and the parametrized fixture that will run them against the fake and against the real one. But before typing a single assertion, there are two method decisions that define whether your contract helps or hinders, and they're the ones the capstone evaluates: which seam deserves the contract and where its clauses come from. A badly chosen contract —over the wrong seam, or full of details nobody uses— is worse than none: it gives false confidence and slows down every change. A well-chosen contract is the exact checklist of what two components promise each other.

The rule that governs the clauses is module 3's, and you're going to apply it until it's reflex: the consumer commands. The clauses aren't invented by whoever implements the repository; they're dictated by the real needs of BookingService, the one that uses it. Each line of BookingService that touches the repository is a need, and each need calls for a clause. That's why the contract is small and honest: only what the consumer really needs gets in, neither the provider's whole catalog nor SQLite's internal details. In this lesson you do that work explicitly —from the use to the clause— and leave the battery written and ready to verify, in the next two lessons, against the fake and against the real one.

Connection to the module: this lesson produces deliverable 1 and lays the foundation for the other two. The contract you write here is the one lessons 3 and 4 run against the fake and the real one (deliverable 2), and the same one that in lesson 7 will catch a breaking change. Here you don't run it thoroughly yet —you only confirm with --collect-only that the battery expands as it should—; running it in green is the work of the following lessons. What you leave ready is the instrument: four clauses derived from real consumer needs, mounted in a parametrized fixture that doesn't duplicate a line.

Analogy: the house buyer's requirements letter

When you buy a house, before signing you hire an inspector. And here there are two ways to work. In the bad one, the inspector arrives with their generic list —they check hundreds of things that came from the factory, many you don't care about— and hand you a two-hundred-page report where what really worries you (do the pipes hold?, does the roof leak?) is buried among data you'll never use. In the good one, you, the buyer, hand them a requirements letter: "I need the electrical installation to support the consumption of a modern kitchen, no pipe to drip, the roof not to leak in the rainy season, and the windows to close". The inspector verifies that, and only that, with a concrete test for each point. The list is short, it's yours, and each line exists because it matters to you.

The consumer-driven contract is the requirements letter, and BookingService is the buyer. You don't tell the repository how to save —whether with a dict, a table, or a file, that's the provider's freedom, just as the inspector is free to use whatever instrument they want—; you tell it what you need to be able to trust: that saving and reading recovers the same booking, that requesting an absent one fails, that saving twice updates, that listing a room returns its own. Each requirement comes from something BookingService really does. And that's why the contract stays small: it doesn't include "the repository assigns an internal rowid" or "uses a B-tree index", because the buyer didn't ask for it —doesn't use it—. The buyer's short and precise letter is a contract; the generic two-hundred-page report is noise.

Decision 1: which seam, and why the repository

Reservo has two seams where BookingService talks to a collaborator that has a double and a real implementation: the BookingRepository (fake against SQLite) and the PaymentGateway (stub against a payment service). A contract makes sense right there, where a double and a real one can diverge. The capstone chooses the repository, and the reason is that there the risk of divergence is richest and most dangerous:

  • The repository serializes: the datetime is saved as text and has to be reconstructed. The in-memory fake never serializes, so it can hide that bug.
  • The repository has error states the consumer counts on: get of an absent id must raise so cancel reacts. A fake can return None by oversight.
  • The repository persists with transactions: the commit decides whether a write survives. The fake has no commits.
  • The repository updates: saving the same id twice must replace, not duplicate. A badly written fake could accumulate copies.

Each of those behaviors is a candidate clause, and each is a place where the fake and the real one can separate without a unit test noticing. That's why the repository is the seam that teaches best —and protects best—. The gateway could also have its contract (you'll see it in the exercises), but its divergence is less varied, so as a practice piece and as protection, the repository wins.

Decision 2: from the consumer's need to the clause

Now the consumer-driven work. Look at what BookingService really does with the repository, and let each use call for its clause:

# reservo/services.py — the CONSUMER (excerpt of what touches the repository)
class BookingService:
    def book(self, room, member, start, end):
        existing = self._repo.find_by_room(room.id)   # (D) needs to LIST a room
        # ...validates availability, charges...
        self._repo.save(booking)                       # (A) needs to SAVE and recover
        return booking

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

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

  • (A) book saves and cancel expects to recover that same booking. If saving and reading didn't return the same booking, cancel would miscalculate the refund. → Clause 1: save-and-read returns the same booking.
  • (B) cancel reads, and if the id doesn't exist it needs to find out. If get returned None, the next line would blow up with a cryptic error. The consumer needs get to raise so it can react cleanly. → Clause 2: get of an absent id raises.
  • (C) cancel saves the same booking again, now cancelled. It's the same id; the consumer needs this to update the status, not to create a second phantom booking. → Clause 3: save twice of the same id updates, doesn't duplicate.
  • (D) book lists a room's bookings to check availability. It needs find_by_room to return all and only that room's bookings, without dragging in others'. → Clause 4: find_by_room returns only that room's bookings.

Notice what does not appear. There's no clause about a column's name, or about a rowid, or about which index SQLite uses. BookingService touches none of that, so none of that gets into the contract. The contract is the exact portrait of what the consumer uses —the edge between the two components seen from the caller's side—.

The battery, written

With the four clauses derived, you write the battery. You need a helper that fabricates a sample booking (with Reservo's anchors: Focus 3 h for Ana pro, 6000 cents) and the parametrized fixture that hands over, in different runs, the fake and the real one.

# tests/test_repository_contract.py — THE CONTRACT (deliverable 1)
import sqlite3
from datetime import datetime

import pytest

from reservo.doubles import FakeBookingRepository
from reservo.models import Booking
from reservo.sqlite_repo import SqliteBookingRepository

START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)      # Focus 3 h


def a_booking(id="bk-1", room_id="focus", status="confirmed", price_cents=6000):
    return Booking(id=id, room_id=room_id, member_id="m-ana",
                   start=START, end=END, status=status, price_cents=price_cents)


# The parametrized fixture: each clause runs against the fake AND against real SQLite.
@pytest.fixture(params=["fake", "sqlite"])
def repo(request):
    if request.param == "fake":
        return FakeBookingRepository()
    return SqliteBookingRepository(sqlite3.connect(":memory:"))


# Clause 1 (need A): save-and-read returns the same booking.
def test_save_then_get_returns_the_same_booking(repo):
    booking = a_booking()
    repo.save(booking)
    assert repo.get("bk-1") == booking


# Clause 2 (need B): get of an absent id raises.
def test_get_of_a_missing_id_raises(repo):
    with pytest.raises(KeyError):
        repo.get("does-not-exist")


# Clause 3 (need C): save twice of the same id updates (doesn't duplicate).
def test_saving_the_same_id_twice_updates_not_duplicates(repo):
    repo.save(a_booking(status="confirmed"))
    repo.save(a_booking(status="cancelled"))     # same id "bk-1"
    assert repo.get("bk-1").status == "cancelled"
    assert len(repo.find_by_room("focus")) == 1


# Clause 4 (need D): find_by_room returns only that room's bookings.
def test_find_by_room_returns_only_that_rooms_bookings(repo):
    repo.save(a_booking(id="bk-1", room_id="focus"))
    repo.save(a_booking(id="bk-2", room_id="studio"))
    found = repo.find_by_room("focus")
    assert [b.id for b in found] == ["bk-1"]

Each clause is written in terms of the public interfacesave, get, find_by_room—, never of the implementation. That's why the fake (a dict) and the real one (a table) can both meet them: no clause says "there's a row in the table", which would only make sense for SQLite. That discipline is what makes the contract shared.

Worked example: confirm the expansion with --collect-only

Before running the assertions, it's worth verifying that the battery expands as it should: four clauses times two providers, eight cases. Pytest can collect the tests without executing them, with --collect-only:

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

python3 -m pytest tests/test_repository_contract.py --collect-only -q
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[fake]
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[sqlite]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[fake]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[fake]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[fake]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite]

8 tests collected in 0.01s

You wrote four test functions; pytest collected eight cases. Each clause appears twice, one with [fake] and another with [sqlite], because the parametrized fixture runs it once for each value of params. The text in brackets is the parameter's id, your map for reading, when something fails, against which provider it failed. The count is simple and worth keeping clear: cases = clauses × implementations. The contract is written and well-formed; in lesson 3 we run it against the fake, and in lesson 4 against the real one.

Common mistakes

Letting the provider dictate the clauses. What happens: you write the contract looking at what the SqliteBookingRepository does today —"we promise find_by_room returns in rowid order", "the ids are of such a format"— instead of what BookingService needs. Why it happens: the implementation is what you have at hand; describing your code is easier than asking what the consumer uses. How to detect it: if a clause mentions a detail no method of BookingService uses, the provider dictated it. How to fix it: for each clause ask yourself "what line of BookingService would break if this weren't met?". If the answer is "none", the clause is superfluous. The contract is the consumer's needs, not the provider's inventory.

Writing clauses only one implementation can meet. What happens: a clause asserts about how the provider saves —"after save, there's a row in the bookings table"—. Why it happens: it's tempting to verify what's easy to see from inside SQLite. How to detect it: if the clause doesn't make sense for the fake (which has no table), it's not a shared contract —the fake couldn't meet it even if it wanted to—. How to fix it: write each clause only with the public interface. "After save(b), get(b.id) returns b" is met by the dict and the table; "there's a row" isn't. A contract that a legitimate provider can't meet because of its technology isn't a contract, it's a bias toward one implementation.

Inflating the contract "just in case". What happens: clauses are added about behaviors no consumer uses today —"save returns the assigned id", "find_by_room comes ordered by date"— thinking more coverage is better. Why it happens: it seems prudent to over-promise. How to detect it: if on changing the provider a clause goes red but no consumer would break in production, that clause protects something nobody cares about and slows down legitimate changes. How to fix it: the consumer-driven contract is exactly as big as the real use. If tomorrow a real consumer needs the order, then the clause is added, driven by that need. Not before. An inflated contract ages into a burden nobody dares to touch.

Exercises

Exercise 1 — A new need, a new clause. A screen appears that lists a room's bookings that may have none, and needs find_by_room of an empty room to return an empty list, not None or an exception. Write the clause as a parametrized test and say, without running it, whether the fake and the real one pass it.

See solution
# Clause 5 (need: list a room that may be empty): empty list, not None.
def test_find_by_room_of_empty_room_returns_empty_list(repo):
    assert repo.find_by_room("nonexistent-room") == []

Both pass it. The FakeBookingRepository.find_by_room is a list comprehension over the dict: with no bookings of that room, it returns []. The SqliteBookingRepository.find_by_room does a SELECT ... WHERE room_id = ? and a fetchall(): with no matching rows, fetchall() returns an empty list, so also []. The battery would be at 10 passed (5 clauses × 2 providers).

The important thing is where the clause came from: from a real consumer need (a screen that lists a room that may be empty), not from a whim. Even though today both providers already meet it "by coincidence" of how they're written, making it a clause turns it into a watched promise: if tomorrow someone writes a provider whose find_by_room returns None for an empty room, the battery would catch it. Each edge you state —driven by a need— is a door you close.

Exercise 2 — The superfluous clause. The SqliteBookingRepository team proposes adding: "clause: save assigns each booking an incremental rowid accessible by repo.last_rowid()". No method of BookingService calls last_rowid(). Should it get into the consumer-driven contract? Justify with the two tests that discard it.

See solution

It shouldn't get in, and two tests confirm it:

  1. The consumer test: no consumer —BookingService nor the screen— calls last_rowid() or depends on any rowid. The clause is dictated by the provider (it talks about an internal SQLite detail), not a real need. Control question: "what line of BookingService would break if this weren't met?". None. The clause is superfluous.
  2. The "meetable by any provider" test: the FakeBookingRepository (a dict) has no rowid or last_rowid(). If this clause got into the shared contract, the fake couldn't meet 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 premise of the shared contract.

The clause stays out. If one day a real consumer needed an incremental identifier, then it would be added —driven by that need, expressed in terms any provider can meet—, not because the provider has it at hand.

Exercise 3 — A contract for the gateway. Suppose you decide to also write a contract for the PaymentGateway. BookingService.book does self._payments.charge(amount_cents) and expects a Receipt with ok=True when the charge goes through. Write the consumer's need and the clause it calls for, and say which providers it would run against (think of the stub and a future real gateway).

See solution

The consumer's need: book charges with charge(amount_cents) and needs to know whether the charge went through to decide whether it confirms the booking. It depends on charge returning a Receipt whose ok tells the truth —True if the charge passed, and on a rejected charge being distinguishable from a successful one (for example, ok=False or an exception), not returning an ambiguous None—.

The clause it calls for:

@pytest.fixture(params=["stub", "real"])
def gateway(request):
    if request.param == "stub":
        return StubPaymentGateway(ok=True)
    return RealPaymentGateway(...)      # the real gateway, in sandbox mode

def test_successful_charge_returns_a_receipt_marked_ok(gateway):
    receipt = gateway.charge(6000)
    assert receipt.ok is True
    assert receipt.amount_cents == 6000

Which providers it would run against: the StubPaymentGateway (the double the tests already use) and a RealPaymentGateway against the real service, ideally in its test mode (sandbox). The value of the contract appears precisely when those two implementations can diverge: if the stub returns a clean Receipt(ok=True) but the real one, for a certain amount, returns None or raises an exception book doesn't handle, the contract would catch it before a broken charge reaches production. The technique is identical to the repository's: clauses driven by the consumer's needs, run against the double and the real one. What changes is the seam; not the method.

Summary and next step

In this lesson you wrote the capstone's first deliverable: the BookingRepository's consumer-driven contract. Before typing you made the two method decisions that make it useful: you chose the repository's seam —because there the fake-vs-real divergence is richest and most dangerous (serialization, error states, transactions, update)— and you derived each clause from a concrete need of BookingService, not from the provider's catalog. With the house buyer's requirements letter you set the image: the contract is short, it's the consumer's, and each line exists because the consumer uses it. You wrote the four clauses in terms of the public interface, mounted the parametrized fixture, and confirmed with --collect-only that the battery expands to eight cases.

Before moving on you should be able to: choose a contract's seam justifying why it runs the risk of diverging; translate a concrete use of the repository in BookingService into the clause it calls for; and discard from the contract what only the provider wants (internal details, promises nobody uses).

The contract is written, but written isn't verified. In lesson 3 you run it against the FakeBookingRepository and see the four [fake] cases in green —and, more importantly, you understand why that green, alone, doesn't prove anything yet—. It's the first side of the second deliverable, and the door to the second side, the real one, which is where the contract starts to be worth it.

Resources