Back to articles
A Bounded Context Doesn't Need a Folder. It Needs a Test That Can Fail.
Jun 17, 2026
I almost split two classes into their own folders to "enforce" a bounded context. Instead I wrote one test. It caught a real violation on its first run.
Architecture in Practice, Book 2: Learning Domain-Driven Design (Vlad Khononov, O'Reilly 2021) Study artifacts: swbook-labs
A test that can fail protects a boundary better than a folder that just looks right. Reading the bounded-contexts chapter in Learning Domain-Driven Design forced that choice for two models in Synergym that had quietly become the most important concept in the app.
I was one git mv away from renaming two classes into their own folders, to make a boundary that already existed in my head exist in the file system too.
Then I counted what that rename would actually touch, and wrote a test instead.
The Problem
Two models in Synergym, the program assignment and the trainer-athlete connection, had quietly become the most important concept in the whole app. Five validation methods on one class. A fourteen-day expiry window with a reminder cadence on the other. Nobody had named this. It just accumulated, the same way the 874-line controller from the last book accumulated.
Reading the bounded contexts chapter, the obvious next step was to draw the map: which models speak the language of coaching, which speak the language of the exercise catalog, which belong to identity, which belong to content.
"Conformist" is Khononov's term for a downstream model that accepts an upstream model as-is, with no translation layer in between.
Coaching, Library, and Content all read Identity's user_id directly. Cheap while Identity's shape stays stable, risky the day it doesn't.
That part was easy. Four contexts, four short paragraphs, done in an afternoon.
The uncomfortable follow-up question came right after: nothing in the code enforces any of it. I could write a line tomorrow that reaches from the catalog straight into coaching's own status field, and nothing would stop me until it broke in some unrelated way, much later, far from the line that caused it.
The Decision
The textbook move is a real wall: a module per context, Coaching:: next to Library::, two folders instead of one.
That is exactly how a DDD book teaches this to programmers who think in classes and namespaces, and for a few minutes I was sold on it.
Then I counted the actual blast radius. Two classes, referenced across controllers, views, routes, mailers, jobs, and every spec and factory that mentions either of them. For a boundary that today has exactly two real places where one context reads into the other. Renaming two classes to prove a boundary that has barely been crossed is a lot of motion for very little protection.
I chose the cheaper thing. Write a test that watches the two specific seams where contexts touch, and fails the build the moment a violation appears there. No folder. No module. A promise enforced by CI instead of by where a file happens to live.
The Implementation
The test itself is small. It reads the source of each context's files and fails if a catalog file hardcodes coaching's own status words, or if a content file references a model outside its own context.
I ran it for the first time expecting a clean pass, mostly to confirm the test worked. It failed immediately, on code that had been sitting untouched for a while. The catalog model's own "update status from assignments" method was reaching directly into coaching and hardcoding the literal word for "active" status, instead of asking the coaching model what active means in its own language. The boundary had already been crossed. I just had nothing that would have told me.
I fixed the one real line that crossed it, routing the catalog through coaching's own definition of active. I also added the guarded transition commands the chapter on tactical patterns argues for: explicit methods that refuse an invalid state change, instead of a callback that lets any status become any other status.
The Trade-off
A test is not a wall. Nothing stops a future version of me from reaching across the boundary by hand at eleven at night, and the test only catches the exact shape of violation I taught it to recognize. A real module boundary would catch every kind of crossing, not only the ones I anticipated.
I am trading completeness for cost. I get protection against the two crossings that exist today, for a fraction of the work a rename would have cost. I wrote down the exact condition that would make the real wall worth paying for: a third genuine crossing point, or the day this context needs to become its own deployable service. That condition lives in the ADR amendment, not just in my head. Until then, "someday" is not the plan. That specific condition is.
The Operating Rule
Before reaching for the structural fix, the rename, the namespace, the new module, write the test that would fail if the violation happened again. If that test is cheap to write and it catches something real, the structural fix can wait for the day it is actually needed.
Why It Matters
The bounded contexts chapter assumes you are free to draw a folder structure around your model. A live solo codebase asks a cheaper question first: what is the smallest thing I can write today that would tell me the truth tomorrow?
A boundary nobody can violate without a test noticing is a real boundary, even with no folder underneath it.