Module 6: Real Boundaries Db Files Http

2. What a boundary is

Description

Lesson 1 gave you the intuition —the doors of your house— and Reservo's three boundaries. Now we have to turn that intuition into a definition with an edge, because everything that follows depends on it: if you don't precisely distinguish a boundary from what isn't a boundary, you won't know what to test in each lesson or why. The word is used loosely —"the system boundary", "a boundary test"— and it's worth pinning down. A boundary is the point where your code stops operating on objects that live in your Python memory and touches a resource you don't control: the disk, the network, a database engine, the system clock. On this side of the boundary, you're in charge —you create objects, pass them, compare them, and everything is deterministic and yours—. On that side, another system is in charge, with its serialization rules, its persistence, its timings, and its ways of failing.

There's a fine distinction this lesson exists to nail, because it's where most people get tangled: a seam is not a boundary. Recall module 1's seam —the point where BookingService connects with the BookingRepository through an interface—. That seam is a place where you choose between putting a double or putting the real thing; it's a design concept, a joint in your code. The boundary is another thing: it's what's on the real side of that seam when you choose the real thing. If at the repository seam you put the FakeBookingRepository, you cross no boundary: the fake lives in your memory. If you put the SqliteBookingRepository, you cross the database boundary: now there's a SQLite engine on the other side. The seam is the door; the boundary is what's there when you cross it. An interface may or may not lead to a boundary; the repository's leads to the database, the clock's leads to the system-time boundary, the gateway's leads to the network.

Connection to the module: this lesson is the definition that orders the four boundaries that follow. Lessons 3 and 4 exercise the database one, lesson 5 the file one, lesson 6 the HTTP one; all are cases of what we define here in the abstract. And it prepares lesson 7's rule: if you know a boundary is "a resource you don't control", you understand why the rule says double what you don't control on the path you don't test, touch the real thing at the boundary you do test. The border with module 7 is respected: here we define what a boundary is and why it matters; how to isolate the real state that lives on the other side —so the tests don't step on each other— is there.

Analogy: a store counter

Think of a store counter as the line separating two worlds. On your side of the counter, you're in control: you choose the product, look at it, set it down, change your mind, all without consequences and at your own pace. The moment you put the product on the counter and pay, you cross a line: the product enters the store's system —its inventory, its register, its sales database—, and there you're no longer in charge. The sale is recorded even if you leave; the system can be slow; the card can be declined; the receipt comes out in the store's format, not yours. The counter is the boundary: the line where your controlled world touches another system with its own rules.

Now notice something subtle, which is exactly the seam/boundary distinction. The counter itself —the piece of furniture, the place where the exchange happens— is the seam: the contact point. What's on the other side —the store's system, real, with its inventory and its register— is the boundary. You could, to rehearse, put a friend on the other side of the counter who pretends to charge and gives you a fake receipt: then the counter is still there (the seam), but you crossed no real boundary —your friend is a double, not the store's system—. The seam is the counter; the boundary is that there's a real system on the other side. In Reservo, the repository seam is the save/get interface; the boundary is that on the other side there's a real SQLite serializing to disk. Testing the boundary is putting the real system on the other side of the counter and seeing whether the exchange works with its rules in place.

The seam and the boundary, with code

Let's see it in Reservo, because the contrast is clean. Here's the repository seam with a double on one side and with the real thing on the other. The seam —the way of calling it— is identical; what changes is whether there's a boundary on the other side.

# The SAME seam (the repository's interface), twice:

# (1) with a double: NO boundary is crossed
repo = FakeBookingRepository()          # stores in a dict, in YOUR memory
service.book(FOCUS, ANA, START, END)    # everything happens inside the process

# (2) with the real thing: the database boundary is crossed
repo = SqliteBookingRepository(sqlite3.connect("reservo.db"))  # SQLite engine
service.book(FOCUS, ANA, START, END)    # the booking crosses to the disk, as text

In (1), service.book runs its logic and repo.save puts the object in a dict. Nothing leaves Python's memory: no serialization, no disk, nothing you don't control. The seam exists —there's a save/get interface— but it doesn't lead to any boundary, because on the other side there's a double. In (2), the same line repo.save crosses into SQLite: the object is serialized to a row of text, written to a file on the disk, and a transaction decides when it's permanent. There a boundary does exist, with all its rules. The seam didn't change; what changed is what's on the other side. That's the whole distinction.

Worked example: the boundary leaves a trace on the disk

The most conclusive way to see a boundary is to check that it leaves a trace outside your process. A double can't: it lives in your memory and disappears with it. A file or database boundary writes bytes to the disk that are still there. Let's prove it with two twin tests: booking with the fake creates no file; booking with SQLite in a file does create a real file, with real bytes, on the disk.

# tests/test_what_is_a_boundary.py — the boundary leaves a trace; the double doesn't
import sqlite3
from datetime import datetime

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)
CLOCK = datetime(2026, 3, 1, 9)


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


def test_fake_repo_crosses_no_boundary(tmp_path):
    # The fake stores in a dict in your process: it doesn't touch the disk.
    repo = FakeBookingRepository()
    make_service(repo).book(FOCUS, ANA, START, END)
    assert list(tmp_path.iterdir()) == []          # nothing was left on the disk


def test_sqlite_file_repo_crosses_the_disk_boundary(tmp_path):
    # SQLite in a file writes real bytes to the disk: it crosses the boundary.
    path = tmp_path / "reservo.db"
    repo = SqliteBookingRepository(sqlite3.connect(path))
    make_service(repo).book(FOCUS, ANA, START, END)
    assert path.exists()                           # the file exists
    assert path.stat().st_size > 0                 # and it has real bytes

The tmp_path is a temporary directory pytest gives you clean (we study it in depth in lesson 5); here we use it as "the disk" and check what's left in it. The first test books with the fake and asserts that the directory stays empty: the booking was stored in a dict, without touching the disk. The second books with SQLite in a file and asserts that the file exists and has bytespath.stat().st_size > 0—: the booking crossed the boundary and left a trace outside your process.

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

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

tests/test_what_is_a_boundary.py::test_fake_repo_crosses_no_boundary PASSED [ 50%]
tests/test_what_is_a_boundary.py::test_sqlite_file_repo_crosses_the_disk_boundary PASSED [100%]

============================== 2 passed in 0.02s ===============================

Two greens that state the difference without rhetoric. With the fake, the disk stayed empty: the repository seam led to no boundary, everything happened in Python's memory. With SQLite in a file, a file with real bytes was left: the same seam, but this time on the other side there was a boundary —the file system— and the booking crossed it. A double would never pass the second test, because a double doesn't write to the disk; and that's precisely the operational definition of a boundary: the point where your code leaves a trace —or depends on a resource— outside your process.

Three things that only happen at a boundary

It's worth naming what changes when crossing a boundary, because they're the three sources of every integration bug and every friction in this module.

One: serialization. On this side you have Python objects —a datetime, an int, a Booking—. On that side, almost no resource understands them: SQLite stores text and numbers, a file stores bytes, HTTP transports text. Crossing the boundary converts your objects to another format, and on the way back they have to be reconstructed. There the datetimestr bug from module 1 is born: the boundary serialized the datetime to text and nobody reconstructed it. A double serializes nothing —it stores the object as-is—, so it never exposes this problem. Only the boundary does.

Two: persistence and externality. What crosses a boundary exists outside your process: the row is still in the SQLite file when you close the connection, the CSV is still on the disk when the test ends, the sale is in the store's system when you leave. That's a virtue (that's why we persist data) and a danger (that's why a test can leave garbage that breaks the next one). Isolating that external state is module 7's topic; here it's enough to know it exists.

Three: non-determinism and latency. On this side, a call returns instantly and always the same. On that side, you don't control it: the disk can be slow, the network can lag or go down, the server can respond late or with an error. That's why a boundary test needs care a unit test doesn't —timeouts, ephemeral resources, order tolerance— so as not to become slow or intermittent. That discipline is lesson 7.

And here's the tension that names half the guide: the boundary is at once where doubling tempts most —because it's slow, external, and non-deterministic— and where the bugs hide most —because the serialization, the persistence, and the non-determinism only happen there—. Doubling the boundary gives you speed and determinism, but blinds you to exactly the place where the failure lives. That's why we don't double all the boundaries: we double the ones we're not testing and leave real the one we are. Lesson 7 turns it into a rule; this whole module is learning to touch the correct boundary without inheriting the cost of all of them.

Common mistakes

Confusing the seam with the boundary. What happens: someone says "I tested the repository boundary" when they actually used the FakeBookingRepository. Why it happens: the seam (the interface) is the same with the fake and with the real thing, so they feel identical. How to detect it: ask yourself whether on the other side of the seam there's a resource you don't control —a database engine, a file, a server— or an object in your memory. If it's an object in your memory, you crossed no boundary. How to fix it: the seam is where you choose; the boundary is what's on the real side. Testing the boundary requires putting the real resource on the other side, not just calling the interface.

Believing "fast and in memory" is never a boundary. What happens: someone uses sqlite3.connect(":memory:") and concludes that, since it's memory and fast, it crosses no boundary. Why it happens: "in memory" sounds like "inside my process". How to detect it: ask yourself whether the resource has its own rules of serialization, transactions, and types —even if it lives in RAM—. :memory: is a complete SQLite engine: it serializes to rows, has transactions, returns the datetime as str. It crosses the database boundary even though it doesn't touch the disk. How to fix it: the boundary is defined by who's in charge on the other side (an engine with its rules), not by whether there's a disk. :memory: is real SQLite; a dict isn't. Lesson 4 develops exactly this difference.

Treating all boundaries as one. What happens: someone tests the database boundary carefully but assumes the file and HTTP ones "are similar" and neglects them. Why it happens: "external resource" sounds like a single category. How to detect it: if your HTTP test doesn't consider timeouts, or your file test doesn't consider serialization to text, you're applying one boundary's intuition to another. How to fix it: each boundary has its own technique —transactions in the database, tmp_path in files, ephemeral server and timeout in HTTP—. They share the idea (a real resource you don't control) but not the tools. That's why the module dedicates a lesson to each one.

Exercises

Exercise 1 — Seam, boundary, or neither. For each situation, say whether it describes a seam (a connection point in your design), a boundary (an external resource on the real side), or neither (pure logic): (a) the BookingRepository interface with its save/get methods; (b) the SQLite engine writing a row to reservo.db; (c) the function price_cents(room, member, hours); (d) the HttpPaymentGateway making a POST to a server; (e) the point where BookingService receives its payments collaborator through the constructor.

See solution
  • (a) Seam. BookingRepository is an interface: the design point where BookingService connects with some repository. It's where you choose to double or integrate; it's not a resource, it's a joint.
  • (b) Boundary. The SQLite engine writing to reservo.db is an external resource with its rules —serialization, transaction, disk—. It's what's on the real side of the repository seam.
  • (c) Neither. price_cents is pure logic: it takes data, returns an integer, without connection to collaborators or an external resource. Neither seam nor boundary; the domain.
  • (d) Boundary. The POST to a server crosses the network into a resource you don't control —the HTTP boundary—. (The corresponding seam is the PaymentGateway interface; the POST is crossing it into the real thing.)
  • (e) Seam. The point where payments is injected is the payment seam: the joint through which the collaborator enters. Whether on the other side there's a StubPaymentGateway (no boundary) or an HttpPaymentGateway (with an HTTP boundary) you decide there.

The rule you're sharpening: the seam is a design concept (where the pieces connect and where you choose); the boundary is a resource concept (what's on the real side, outside your process). A seam can lead to a boundary or to a double; pure logic has neither.

Exercise 2 — Why :memory: does cross a boundary. A colleague says: "a :memory: database lives in RAM, just like the FakeBookingRepository's dict; both are in memory, so neither crosses a boundary". Explain why they're wrong, and what :memory: proves that the dict doesn't.

See solution

They're wrong because they confuse where the data lives (RAM, in both cases) with who's in charge of it and by what rules (what defines a boundary). The fake's dict is a Python data structure: it stores the Booking object as-is, without its own rules; you're completely in charge. A sqlite3.connect(":memory:") database is a whole SQLite engine that happens to be in RAM: it has a schema with column types, serializes the object into a row of text and numbers, manages transactions with commit and rollback, and when reading returns the datetime as str because the column is TEXT. All those are the resource's rules, not yours: you cross its boundary.

What :memory: proves and the dict doesn't is precisely everything that happens at the boundary: the serialization (the datetimestr, the int that does survive), the transaction behavior, the reconstruction of the object from columns. The dict never serializes anything, so it never exposes the datetime bug or exercises a transaction. That's why :memory: is a real SQLite useful for testing the database boundary —fast, without disk, but with all the engine's rules in place—, while the dict is a double that stays inside the house. The only thing :memory: does not prove, by living in RAM, is persistence on disk; that's lesson 4.

Exercise 3 — The three boundary things, in one bug. Recall module 1's bug: SqliteBookingRepository.get returns start as str, and a screen that formats the date breaks. Explain which of the "three things that only happen at a boundary" (serialization, persistence/externality, non-determinism) is the root of that bug, and why no double would have exposed it.

See solution

The root is serialization. SQLite has no native type for datetime, so on crossing the boundary the object is converted to text (.isoformat() in save) to fit in the TEXT column, and on the way back it comes out as str because nobody reconstructs it to datetime. The data changed shape at the boundary: that's serialization, the first of the three things. It's not persistence (the bug appears the same in :memory:, without disk) or non-determinism (it's perfectly repeatable, it always fails the same); it's purely the format conversion the boundary imposes.

No double would have exposed it because a double doesn't serialize: the FakeBookingRepository stores the Booking object in a dict and returns it identical, with its datetime intact, because it never converts it to text. Serialization only happens when there's a real resource on the other side that demands another format —a text table, a file, an HTTP body—. That's why the bug lives exactly at the boundary and only the real piece reveals it: the double stays on this side, where objects never change shape. This is the deep reason for the whole module: the three things that only happen at the boundary are also the three classes of bug that only the boundary exposes.

Summary and next step

In this lesson you nailed the definition that orders the module: a boundary is the point where your code touches a resource you don't control —disk, network, database—, on the other side of a seam. With the store counter you separated the two ideas: the seam is your design's connection point (where you choose to double or integrate); the boundary is what's on the real side when you choose the real thing. You saw it with code —the same repository seam, with a double (no boundary) and with SQLite (with a boundary)— and with pytest output: the fake leaves no trace on the disk, SQLite in a file does. And you named the three things that only happen at a boundary —serialization, persistence/externality, non-determinism—, which are the three sources of every integration bug and the reason the boundary is at once where doubling tempts most and where the failure hides most.

Before moving on you should be able to: distinguish a seam (design concept) from a boundary (external resource); explain why :memory: crosses the database boundary even though it lives in RAM; and name the three things that only happen at a boundary and tie each to a class of bug.

What comes next is crossing the first boundary in depth. In lesson 3 we open the database one by its most characteristic rule —the transaction—: you're going to see with real output what a commit does, what a rollback does, and why an uncommitted write is invisible to another connection. It's the exact mechanic that makes SqliteBookingRepository truly persist, and the lever module 7 will use to isolate tests.

Resources