Module 2: The Double That Lied

5. Divergence of uniqueness and transactions

Description

The divergences in lessons 3 and 4 —behavior, types, order— had something in common: the fake, with effort, could have avoided them. You could write a fake that raises instead of returning None, that returns datetime instead of str, that orders by start. They'd be more complex fakes and a worse idea (lesson 2), but possible. This lesson opens two different families, and their defining trait is that the in-memory dict can't fake them without ceasing to be a dict, because they're properties of a real database's machinery, not of the data it stores. They're the module's deepest divergences, and the ones that come closest to the nature of "the real system".

The first is uniqueness, or more generally, constraints: the rules the database enforces on the data. A real database can have a restriction that says "no room is booked twice at the same time" (UNIQUE(room_id, start)), and it will enforce it by rejecting with an error any attempt to violate it. The fake's dict has no notion of rules: it accepts whatever you give it, even an impossible double booking. The second is transactionality: the database's ability to group several writes into an atomic unit —all or nothing— and to undo it (rollback) if something fails halfway. The fake's dict mutates immediately and forever; it doesn't know what a commit or a rollback is, so a failure in the middle of a batch leaves it half-written, dirty, with no way to go back. Constraints and transactions are two things the real one does and the fake can't even represent.

Connection to the module: this lesson closes the catalog of divergences the module collects, and it does so with the two that best illustrate lesson 2's "oversimplifies" cause taken to the extreme. Uniqueness and transactions aren't free guarantees the fake gives away extra (like the types or the order in lesson 4); they're guarantees the real one gives and the fake can't give at all, because it lacks the machinery. That's why they're a perfect bridge to module 3: they make it crystal clear that a double's fidelity isn't achieved by making it fatter —you can't put a transactional engine into a dict without turning it into a database—, but by verifying its behavior against the real one with a contract, and testing every so often against the real thing with integration. Here you see, for the last time and in its most extreme form, why trusting the fake without verifying is a gamble.

Analogy: the personal notebook and the bank's ledger

Think of two ways to keep accounts. The first is a personal notebook: you write down whatever you want, however you want. If you make a mistake and write the same expense twice, the notebook doesn't protest —it's paper, it accepts whatever the hand writes—. And if you start writing a two-step operation ("I take 100 out of here, put 100 in there") and get interrupted halfway, the notebook is left with the first step written and the second not: there's no way for the notebook to "undo" the half-entry; there it stays, inconsistent, until you remember to cross it out.

The second is a bank's ledger, governed by rules and transactions. It has a rulebook it enforces: try to record the same transfer twice with the same reference number and the system rejects it on the spot —"duplicate reference"—; it doesn't let you break the rule. And it has atomicity: a two-step transfer (debit one account, credit another) is recorded as a unit, and if the second step fails, the system undoes the first automatically —the debited account returns to its balance—, so it's never left half-done. The bank's ledger doesn't trust the operator to remember to cross things out; it guarantees consistency with its machinery.

The FakeBookingRepository is the personal notebook: it accepts the impossible double booking, and if a batch fails halfway, it leaves what's written written. The real SqliteBookingRepository is the bank's ledger: it rejects the double booking with an error, and undoes the failed batch with a rollback. If you tested your code only against the notebook, you learned to live in a world without rules and without undoing —where everything is accepted and nothing is reverted—, and the day you connect it to the bank, you find rejections and reversions your code never contemplated. The two divergences in this lesson are the two ways the bank surprises you when you came from the notebook.

Divergence of uniqueness: the double booking the fake accepts

Let's start with constraints, with the most natural business rule of a booking system: a room can't be booked twice at the same time. In a real database this is expressed with a uniqueness constraint in the schema —UNIQUE(room_id, start)—, and the engine enforces it: if you try to insert a second booking for the same room and the same time, it rejects it with an error. Here's the real repository with that rule:

# reservo/sqlite_repo_unique.py — the real one enforces a business rule with UNIQUE
SCHEMA = """
CREATE TABLE IF NOT EXISTS bookings (
    id          TEXT PRIMARY KEY,
    ...
    price_cents INTEGER NOT NULL,
    UNIQUE(room_id, start)          -- no room is booked twice at the same time
)
"""

The FakeBookingRepository, on the other hand, is a dict indexed by id: it knows nothing about room_id or start as a uniqueness key. Two bookings with different id but the same room and the same time are, for the dict, two different keys: it stores both without complaint. Here's the experiment: I save two different bookings (bk-1 and bk-2) for the same room at the same time, and verify that there are two.

# tests/test_uniqueness.py
def a_booking(id_):
    # TWO different bookings (different id) for the SAME room at the SAME time
    return Booking(id=id_, room_id="focus", member_id="m-ana",
                   start=START, end=datetime(2026, 3, 10, 10),
                   status="confirmed", price_cents=6000)


def test_two_bookings_same_slot_with_fake():
    repo = FakeBookingRepository()
    repo.save(a_booking("bk-1"))
    repo.save(a_booking("bk-2"))          # the fake does NOT know the rule: accepts the clash
    assert len(repo.find_by_room("focus")) == 2   # two bookings in the same room/time


def test_two_bookings_same_slot_with_sqlite():
    repo = SqliteBookingRepositoryUnique(sqlite3.connect(":memory:"))
    repo.save(a_booking("bk-1"))
    repo.save(a_booking("bk-2"))          # <-- the real one rejects the clash with UNIQUE
    assert len(repo.find_by_room("focus")) == 2

What to expect. On my machine (Python 3.14.0, pytest 9.1.1), the fake accepts both bookings and passes; the real one rejects the second with IntegrityError:

tests/test_uniqueness.py::test_two_bookings_same_slot_with_fake PASSED   [ 50%]
tests/test_uniqueness.py::test_two_bookings_same_slot_with_sqlite FAILED [100%]

=================================== FAILURES ===================================
___________________ test_two_bookings_same_slot_with_sqlite ____________________

    def test_two_bookings_same_slot_with_sqlite():
        repo = SqliteBookingRepositoryUnique(sqlite3.connect(":memory:"))
        repo.save(a_booking("bk-1"))
>       repo.save(a_booking("bk-2"))          # <-- the real one rejects the clash with UNIQUE
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

tests/test_uniqueness.py:28:
...
E       sqlite3.IntegrityError: UNIQUE constraint failed: bookings.room_id, bookings.start

reservo/sqlite_repo_unique.py:24: IntegrityError
========================= 1 failed, 1 passed in 0.04s ==========================

Notice the direction of the lie, because it's the most dangerous of all. The unit test with the fake passes asserting that the system allows two bookings for the same room at the same time —a double booking, which is exactly the business bug the rule exists to prevent—. The fake, by not modeling the constraint, gave the green light to an impossible state. The real one, with its IntegrityError, tells the truth: that state must not exist. Here the unit test's green doesn't just hide a bug: it certifies the bug as correct behavior. If a developer writes the booking logic trusting this green, they believe their code allows something the real system forbids, and their code isn't prepared for the IntegrityError it will get in production when two members try the same room at the same time. The fake taught it a world without rules, and the real world has rules.

Divergence of transactions: the batch the fake doesn't undo

The second family is atomicity, and we're going to see it with a batch operation: saving several bookings at once, with the "all or nothing" rule. If the batch fails halfway —one of the bookings violates a constraint—, the correct thing is that none stays saved: the whole batch is undone, so as not to leave the system half-done. A real database does this with a transaction and a rollback. The fake's dict can't: each save is an immediate and definitive mutation.

To make the comparison fair and forceful, I use a fake that even models the constraint —it raises if it detects a duplicate id—, so you can't say "the fake didn't even try". The point is that, even modeling the rule, the fake can't undo what's already written, because it has no transactions:

# reservo/batch.py
class ConstraintFakeRepository:
    def __init__(self):
        self._store = {}

    def save(self, booking):
        if booking.id in self._store:
            raise ValueError(f"duplicate id: {booking.id}")   # "constraint"
        self._store[booking.id] = booking


def save_all_fake(repo, bookings):
    for b in bookings:            # without transaction: each save is final
        repo.save(b)

The real repository, on the other hand, wraps the batch in a transaction: if any INSERT fails, it does rollback() and nothing stays. The batch we're going to test has a clash planted at the end: [bk-1, bk-2, bk-1] —the third element repeats the id bk-1, violating the PRIMARY KEY's uniqueness—. The question is what stays saved after the batch fails:

# tests/test_transactions.py
# The batch has a clash at the end: bk-1 repeats (violates the id's uniqueness).
BATCH = [a_booking("bk-1"), a_booking("bk-2"), a_booking("bk-1")]


def test_failed_batch_leaves_nothing_with_fake():
    repo = ConstraintFakeRepository()
    with pytest.raises(ValueError):
        save_all_fake(repo, BATCH)
    assert repo.count() == 0     # the fake should stay clean... or not?


def test_failed_batch_leaves_nothing_with_sqlite():
    repo = SqliteBatchRepository(sqlite3.connect(":memory:"))
    with pytest.raises(sqlite3.IntegrityError):
        repo.save_all(BATCH)
    assert repo.count() == 0     # the real one rolls back: 0 rows

What to expect. Here the lie changes direction: it's the fake that fails, leaving dirty state, while the real one stays clean:

tests/test_transactions.py::test_failed_batch_leaves_nothing_with_fake FAILED [ 50%]
tests/test_transactions.py::test_failed_batch_leaves_nothing_with_sqlite PASSED [100%]

=================================== FAILURES ===================================
__________________ test_failed_batch_leaves_nothing_with_fake __________________

    def test_failed_batch_leaves_nothing_with_fake():
        repo = ConstraintFakeRepository()
        with pytest.raises(ValueError):
            save_all_fake(repo, BATCH)
>       assert repo.count() == 0     # the fake should stay clean... or not?
        ^^^^^^^^^^^^^^^^^^^^^^^^
E       assert 2 == 0
E        +  where 2 = count()

tests/test_transactions.py:25: AssertionError
========================= 1 failed, 1 passed in 0.03s ==========================

assert 2 == 0: the fake was left with two bookings saved (bk-1 and bk-2) after the batch failed on the third element. Since save_all_fake has no transaction, it wrote bk-1, wrote bk-2, and on reaching the repeated bk-1 it raised —but the first two were already in the dict, and there they stayed—. The system was left half-done: a batch that should have been "all or nothing" turned out "something". The real one, which wraps the batch in a transaction, did rollback on failure and stayed at zero: all or nothing, for real.

This case is especially instructive because the lie points the opposite way from the uniqueness one: here the one giving the dangerous result is the fake, not the real one. And yet the moral is identical: the fake and the real one disagree, and believing the fake leads you to a false conclusion about the real system. If your "a failed batch leaves no dirty state" test runs against the fake, it goes red and makes you think your batch code has an atomicity bug —when in reality, against the real engine and its transaction, the code works fine—. Or the reverse, if your code depends on atomicity and you only test it against the fake without a transaction, you never verified that the rollback actually happens. Atomicity is a property of the real one the fake can't represent, so any atomicity test against the fake is, at best, noise, and at worst, an inverted conclusion.

Why these two aren't fixed by fattening the fake

It's time to finish off the argument lesson 2 opened. Faced with the type and order divergences, someone could propose, with some justification, "I make the fake more faithful: have it return datetime, have it order". Faced with uniqueness and transactions, that way out closes entirely, and it's worth seeing why. For the fake to enforce UNIQUE(room_id, start), it would have to keep an index of the room-time combinations already used and check it on every save —you start reimplementing a database's constraint engine—. For the fake to support transactions, it would have to save copies of the state before each batch and know how to restore them on a failure —you start reimplementing the transactional engine—. In the limit, a fake that models constraints and transactions is an in-memory database, with all its complexity, its own bugs, and no guarantee of matching the real database you actually use in production (which may be PostgreSQL, with its own rules and transactional semantics).

That dead end is the best proof that fidelity isn't chased by reimplementing the real thing inside the double. It's chased another way: by measuring it. A contract asserts "saving two bookings for the same room and time fails" and "a batch that fails halfway leaves no state", and runs those assertions against the real implementation —the one that actually has constraints and transactions— to verify it fulfills them. The simple fake stays for what it's good at (fast unit tests of the logic that does not depend on constraints or transactions), and the properties only the real one can give are verified against the real one, in integration tests. There's no fake faithful enough for this; you have to go to the real piece. And that is, exactly, the reason for being of modules 5, 6, and 7.

Common mistakes

Modeling business rules only in the application code and "trusting" that the fake is enough. What happens: someone puts the "no double bookings" validation in BookingService and tests with the fake, without a constraint in the database. Why it happens: validating in the code feels sufficient, and the fake confirms it. How to detect it: ask yourself what happens if two concurrent requests pass the code's validation at the same time and both try to save —without the constraint in the database, both are saved, and you have the double booking you thought was impossible—. The fake, single-threaded and without a constraint, never shows you that race. How to fix it: data integrity rules live in the database (constraints) in addition to in the code, and they have to be tested against the real one, because the fake can't enforce them or show you the cases where they matter.

Writing an atomicity test against the fake and believing its verdict. What happens: someone tests "a failed batch leaves no state" against the FakeBookingRepository and draws conclusions about the rollback. Why it happens: it's the hand-written repository, the easiest to instantiate in a test. How to detect it: the fake has no transactions, so its result in an atomicity test says nothing about whether your code actually rolls back —the fake can't even roll back—. The verdict is noise. How to fix it: atomicity is only tested against something that has it, i.e., against the real SqliteBookingRepository (or the production engine). Any transaction test that doesn't touch a real transactional piece is testing the void.

Assuming that if the fake passes a "harder" case, it'll pass the easy ones. What happens: someone sees that the ConstraintFakeRepository even raises on a duplicate id and concludes "this fake is robust, it models the rule". Why it happens: a fake that validates something seems more trustworthy than one that validates nothing. How to detect it: modeling the rejection (raising on a duplicate) is one thing; modeling the reversion (undoing what's already written) is another, and requires transactions the dict doesn't have. The ConstraintFakeRepository does the first and not the second, and that's why it leaves dirty state. How to fix it: don't assume that "models one part" implies "models the rest"; each property of the real one (rejection, reversion, isolation, durability) is independent, and the fake can fake some and be unable of others. Verify each one against the real one, not by analogy with the ones it does fake.

Exercises

Exercise 1 — Constraint or transaction? For each divergence, say whether it's a uniqueness/constraint one or a transaction/atomicity one, and in which direction it lies (which repo gives the dangerous result): (a) the fake allows saving a booking with price_cents = -500; the real one rejects it with CHECK (price_cents >= 0); (b) the code transfers a booking from one room to another in two steps (delete from A, create in B); the second step fails; the fake leaves the booking deleted from A and not created in B; (c) the fake allows two bookings with the same id; the real one rejects it with the PRIMARY KEY.

See solution
  • (a) Constraint (a CHECK). The real one enforces a domain rule (non-negative price); the fake, without constraints, accepts the impossible value. The lie is given by the fake: it certifies as valid a negative price the real system forbids. Code that trusts that green won't be prepared for the real one's IntegrityError.
  • (b) Transaction/atomicity. The two-step operation should have been atomic; on the second step failing, without a transaction the booking is left "deleted from A and not created in B" —it disappeared—. The lie is given by the fake (it leaves the state half-done); the real one, with the transaction, would roll back and the booking would still be in A. It's a case of data loss that only the real one's atomicity prevents.
  • (c) Constraint (the PRIMARY KEY). The id's uniqueness is enforced by the real one; the fake indexed by id, curiously, would also enforce it in part (it would overwrite instead of duplicate), but a list-based fake wouldn't. In a dict by id, saving the same id twice overwrites —it doesn't duplicate but loses the first—, while the real one with PRIMARY KEY raises. The direction of the lie depends on the exact fake; the point is that "saving the same id twice" has different behaviors, and only the contract fixes which one is correct.

Exercise 2 — The concurrent double booking. A team validates "no double bookings" only in BookingService (it checks the calendar before saving) and tests with the FakeBookingRepository, all green. In production, with the SqliteBookingRepository that does not have UNIQUE(room_id, start), double bookings appear. Explain how, and which two things were missing.

See solution

How they appear: two nearly simultaneous requests for the same room at the same time. Both run BookingService's validation —"is the calendar free?"— before either has saved, so both see the room free and both pass the validation. Then both save, and since there's no UNIQUE(room_id, start) in the database to stop them, both bookings are written: double booking. It's a classic race condition: the validation in the code isn't atomic with respect to the write, and without a constraint in the database acting as the last line of defense, the window between "I validated" and "I saved" lets the clash through.

What was missing, two things. First: the constraint in the database (UNIQUE(room_id, start)), which is the only defense applied at the exact moment of writing, without a race window —if two writes clash, the engine rejects one with IntegrityError, and the code can handle it—. Second: a test against the real one that exercised this case. The fake, single-threaded and without a constraint, can never show a race condition or the engine's rejection; the bug lives precisely in what the fake doesn't model. Only an integration test against the SqliteBookingRepository with the constraint —attempting the double booking and verifying that the second is rejected— would have revealed, before the deploy, that the validation in the code wasn't enough.

Exercise 3 — Fat fake or contract? A colleague proposes: "I add to FakeBookingRepository a UNIQUE(room_id, start) check and a homemade rollback mechanism, so the fake models constraints and transactions and it no longer diverges". Argue why this is a bad deal and what's done instead.

See solution

Why it's a bad deal: to model UNIQUE(room_id, start), the fake has to keep and query an index of room-time combinations on every save; to model the rollback, it has to snapshot the state before each batch and know how to restore it. As soon as you do that, the fake stops being a five-line dict and becomes a reimplementation of a database engine —with three new problems—. One: complexity, the fake is now non-trivial code that has to be read, understood, and maintained, and that can have its own bugs (a badly done homemade rollback is worse than none, because it seems to protect). Two: it doesn't guarantee a match, your homemade constraint can differ at an edge from SQLite's real constraint (or PostgreSQL's in production), so you still don't know if the fake and the real one really behave the same. Three: it doesn't close cause two, when the real schema gains a new constraint, your fat fake will fall just as far behind as the thin one, and now it's more work to update.

What's done instead: keep the fake deliberately simple —without constraints or transactions— and use it only for what doesn't depend on them (the pure logic of book/cancel, fast and isolated). The properties only the real one gives —uniqueness, atomicity— are verified in two complementary ways: a contract that asserts "saving two bookings for the same room and time must fail" and is run against the real implementation to confirm it fulfills it; and integration tests that exercise those properties against the real SqliteBookingRepository. You don't chase fidelity by reimplementing the database inside the fake (an infinite and fragile task); you measure it against the real database (a bounded and honest task). That's the plan of modules 3, 5, 6, and 7.

Summary and next step

In this lesson you ran the module's two deepest divergences, the ones the dict can't even fake because it lacks the machinery. The uniqueness one: the real SqliteBookingRepository rejects a double booking with IntegrityError while the fake accepts it silently —and there the green doesn't hide a bug, it certifies it as correct—. The transactions one: a batch that fails halfway, which the real one undoes entirely with a rollback (stays at zero) while the fake leaves it half-written (stays at two) —a divergence that points the opposite way but teaches the same thing—. And you finished off lesson 2's argument: these properties aren't achieved by fattening the fake (that's reimplementing a database, with its bugs and no guarantee of matching), but by measuring fidelity against the real one with contracts and integration.

Before moving on you should be able to: explain why the fake can't model uniqueness or transactions without ceasing to be a dict; distinguish "modeling the rejection" from "modeling the reversion"; and argue why an atomicity test against the fake says nothing.

You've seen six divergences in four families —behavior, types, order, uniqueness, transactions—, all run, all with the same outcome: the fake and the real one disagree, and believing the fake leads to a false conclusion. One underlying question remains that we've brushed against without answering head-on: why is the unit test suite, however large and green, unable to catch any of these? It's not bad luck or too few tests; it's structural. Lesson 6 demonstrates it: the double is at once the subject of the test and the oracle that judges it, and asking the double whether the double is right always gives yes.

Resources