Lesson 0022 · System Designer · Module 2
Organizing Modules and Folders Around Boundaries
Folders are the interface your team reads first. Organize by responsibility and boundary — not by file type — and the architecture announces itself in the tree.
Mission tie-in: "organize modules and folders around responsibilities and boundaries" — the layout that keeps a growing application navigable and its dependency arrows visible.
Knowledge: the tree should tell the story
A project organized by file type — models/, services/, controllers/, utils/ — tells you nothing about what the system does. A project organized by boundary — domain, application services, infrastructure, delivery — tells you how change flows before you open a file:
checkout/
├── domain/ # the core: rules, value objects, aggregates (nothing imports out)
│ ├── order.py
│ ├── money.py
│ └── pricing.py
├── application/ # use cases: orchestrate domain via ports
│ ├── checkout_service.py
│ └── ports.py # PaymentGateway, OrderRepository, Inventory…
├── infrastructure/ # adapters: the world conforming to the ports
│ ├── stripe_adapter.py
│ ├── sqlalchemy_repository.py
│ └── email_adapter.py
└── api/ # delivery: HTTP, CLI, whatever the outside speaks
└── endpoints.py
A folder tree is a cache of the answers to two questions every developer — and every AI agent — asks dozens of times a day: where does this go? and what may this touch? A file-type tree answers neither, so both are re-derived, badly, by whoever is typing. That is the whole mechanism, and it explains why the payoff scales with the number of people rather than the number of files.
Underneath sits something stronger. Change is local only when you can enumerate a module's dependents cheaply. One-way arrows make that enumeration a fact about the tree instead of a search across the repo; a cycle makes it impossible, because every member of the cycle is a potential dependent of every other. Foldering by boundary is not tidiness — it is the thing that keeps the blast radius of a change knowable.
Where it stops applying. A tree only pays when there is more than one boundary to keep apart: a 400-line tool with one boundary should be one file, and splitting it buys navigation cost with no isolation. Foldering also assumes you know the boundaries — in a domain you are still discovering, an early tree hardens the wrong seams and every later correction is a mass file move. One package until the third feature reveals the seam is the honest default there. And the four names below are one instantiation, not the pattern: a feature-first tree (returns/, payments/, each with its own domain and adapters) satisfies the same principle and usually ages better past about twenty thousand lines.
The rules that keep it stable:
- Dependency direction is visible in the tree:
domainimports nothing outside itself;applicationimportsdomain;infrastructureimports both;apiimports application. A cycle shows up as an import you can't explain. - Naming by role, not by technology: "stripe_adapter" says what it is; "payments" as a sibling folder to "domain" says nothing.
- No
utils/graveyard: a utility that doesn't belong to any boundary is a cohesion failure waiting to be named. - Boundaries match teams when they can: a boundary that matches a team's ownership (lesson 0033) makes coordination follow the tree.
A convention nobody enforces decays within a quarter. Turn the rules into a test and the tree becomes a fitness function — the arrows fail the build instead of failing review:
# tests/test_architecture.py
FORBIDDEN = {
"checkout.domain": ("checkout.application", "checkout.infrastructure",
"checkout.api"),
"checkout.application": ("checkout.infrastructure", "checkout.api"),
"checkout.infrastructure": ("checkout.api",),
}
def test_dependency_direction() -> None:
for package, forbidden in FORBIDDEN.items():
for module in walk_modules(package):
for imported in imports_of(module):
assert not imported.startswith(forbidden), (
f"{module} imports {imported}: the arrow points outward"
)
Fifteen lines — walk_modules and imports_of are a few more, built on pkgutil and ast — and the boundary is now a fact about the build rather than a promise in a README. (Tools like import-linter do the same job from a config file; what matters is that something checks.)
Worked example: the re-folder nobody could merge
Nine engineers, one repo, about 90,000 lines, laid out as models/ services/ controllers/. Everyone agrees it should be re-foldered. Two previous attempts died in review.
A senior designer does not start with the folders. They start with the imports, because the folders are an opinion and the imports are evidence: a twenty-line script over ast counts cross-folder import pairs. The finding is specific — services imports controllers in fourteen places, because the request-parsing helpers happen to live there. That is the arrow that should not exist, and it names the actual defect: fourteen files, not nine hundred.
The question they ask next is deliberately not "what should the tree look like?" but "for each folder, what is the reason it changes?" — because a folder whose reason to change you cannot state in one clause is not a boundary (lesson 0006), and moving files into it just relocates the confusion.
The rejected option is the tempting one: re-folder everything in a single pull request. It fails on arithmetic rather than on principle. Nine engineers carry roughly twenty open branches at any time; a 900-file move rewrites the import line of nearly every one of them, so each branch owner pays a manual rebase, and git blame stops answering "why is this line here?" for the next quarter — exactly when the team most needs to read its own history.
What ships instead is a ratchet. Add the fitness test with today's violations frozen into an allowlist; forbid new entries; move one folder per sprint and delete rows from the list as they are fixed. The allowlist is a visible architecture-debt ledger — fourteen lines that only ever get shorter.
# tests/test_architecture.py — today's violations are frozen; new ones fail
KNOWN_VIOLATIONS = {
("checkout.application.pricing", "checkout.api.serializers"),
# …13 more. This set may shrink. It may never grow.
}
def test_dependency_direction() -> None:
found = set()
for package, forbidden in FORBIDDEN.items():
for module in walk_modules(package):
for imported in imports_of(module):
if imported.startswith(forbidden):
found.add((module, imported))
assert found <= KNOWN_VIOLATIONS, f"new violation: {found - KNOWN_VIOLATIONS}"
assert not (KNOWN_VIOLATIONS - found), "fixed — delete these from the allowlist"
Five sprints later the set is empty and the second assertion deletes itself. Nothing was merged that a reviewer could not read in one sitting, and no branch owner paid a rebase they had not chosen. The point worth carrying: the tree was never the deliverable — the arrow count was.
† The foldering test: open the tree and point at each folder, saying what it is and what it may import. If you hesitate on a folder's job or its allowed imports, that folder is a boundary that hasn't been drawn.| Folder | May import | Must never import |
|---|---|---|
domain/ | nothing outside itself | application, infrastructure, api, any SDK |
application/ | domain | infrastructure, api, any SDK |
infrastructure/ | domain, application, third-party SDKs | api |
api/ | application (and the domain types it passes) | infrastructure, directly |
- "Boundary foldering means these four folders."
- Domain / application / infrastructure / api is one instantiation. What the principle requires is that each folder has a stated reason to change and a stated import rule. A feature-first tree meets that too, and scales better when features outnumber layers.
- "The tree enforces the architecture."
- Folders enforce nothing — Python will import across any of them. Only a test enforces. A tree without a fitness function is a convention, and conventions decay at the speed of onboarding.
- "
utils/is fine as long as it stays small." - Size is not the defect; ownership is. It grows precisely because nothing repels additions — there is no owner to say "this doesn't belong here", so the marginal cost of one more file is always zero.
- "Moving files is a zero-risk refactor."
- Import paths are a public interface. Moves break entry points, pickles, serialized class paths, dashboards keyed on module name, and every open branch. Ratchet instead of re-folder.
When this is the wrong move
Don't draw boundaries you haven't discovered. A one-file script, a library with a single public module, and a two-week spike are all better flat. In a genuinely new domain, resist foldering until the third feature shows you where the seam actually is — a wrong boundary is more expensive than no boundary, because code starts conforming to it. And never fold to match an org chart you don't have yet: shaping the tree around a team split that may not happen is speculative generality (lesson 0030) applied to directories.
Skill: draw the tree
The best primary key for folder structure is:
A dependency cycle appears between two folders. The cause is usually:
The utils/ folder is a smell because:
Practice on your own code
Draw your project's current folder tree and label each folder's job and allowed imports. Then redraw it by boundary. Count the files whose home changes — those files are your mis-organization, and the redraw is your target layout.
In the lab. The phase-2 checkpoint gives you the returns-resolution service as one flat package with the carrier SDK imported from the rules. You write the import table first, add the failing fitness test with today's violations frozen, and only then move a file.
Reveal: a tree redraw
A project has models/, repos/, views/. The redraw puts models/ → domain/ (rules only), repos/ → infrastructure/ (adapters), and the views/ split between application/ (use cases) and api/ (delivery). The dependency cycle that used to exist between models and repos disappeared with the boundary.
- Explain to a junior, in two sentences, why a folder tree is about dependency direction rather than tidiness.
- Find one import in your own code this week that points the wrong way. Name its cost: what can no longer be changed alone because of it?
- Predict what your tree looks like in a year if nothing enforces the arrows: which folder swells, and who stops being able to reason about it?
Your win
You can draw a boundary-based tree, spot the folders that don't belong to a boundary, and make dependency direction visible in the layout itself.
Read and watch deeper
- Clean Architecture, Martin — ch. 12–14: component cohesion and coupling — the principles behind folder boundaries.
- Architecture Patterns with Python, Percival & Gregory — ch. 4: the package layout this lesson's tree comes from.
- A Philosophy of Software Design, Ousterhout — ch. 4 and 7: module depth and "different layer, different abstraction" applied to folders.
- import-linter — the official docs for the contract types (layers, forbidden, independence) that express this lesson's import table as configuration.
- Watch: ArjanCodes YouTube — search "project structure python".
- Next: lesson 0023 — Ports & Adapters for infrastructure.
- Reference: Glossary — fitness function, boundary.
Show your agent-teacher your folder tree and label every folder's allowed imports together.