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
datetimeis 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:
getof an absent id must raise socancelreacts. A fake can returnNoneby oversight. - The repository persists with transactions: the
commitdecides 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)
booksaves andcancelexpects to recover that same booking. If saving and reading didn't return the same booking,cancelwould miscalculate the refund. → Clause 1: save-and-read returns the same booking. - (B)
cancelreads, and if the id doesn't exist it needs to find out. IfgetreturnedNone, the next line would blow up with a cryptic error. The consumer needsgetto raise so it can react cleanly. → Clause 2:getof an absent id raises. - (C)
cancelsaves 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:savetwice of the same id updates, doesn't duplicate. - (D)
booklists a room's bookings to check availability. It needsfind_by_roomto return all and only that room's bookings, without dragging in others'. → Clause 4:find_by_roomreturns 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 interface —save, 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:
- The consumer test: no consumer —
BookingServicenor the screen— callslast_rowid()or depends on anyrowid. The clause is dictated by the provider (it talks about an internal SQLite detail), not a real need. Control question: "what line ofBookingServicewould break if this weren't met?". None. The clause is superfluous. - The "meetable by any provider" test: the
FakeBookingRepository(adict) has norowidorlast_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
- pytest documentation — Parametrizing fixtures and test functions — the reference for the fixture with
paramsand therequestobject, the mechanism that runs your contract against the fake and against SQLite without duplicating a line. - pytest documentation —
--collect-only— how to ask pytest to collect the tests without executing them, to confirm that the battery expands to the eight cases you expect. - docs.pact.io — The consumer-driven approach — the premise you apply on deriving each clause: the consumer defines the contract and the provider is verified against it; the industry reference for what you build by hand.
test-doubles-and-test-data-guide— the sister guide where you built theFakeBookingRepositoryand its collaborators; useful for recalling the consumer/provider seam before formalizing it as a contract.