ADR-0030: Canonical Generated Project Layout
Status: Accepted
Date: August 2026
Context
protean new stamps a project from src/protean/template/. That project's shape is
not arbitrary: element discovery and, downstream of it, the IR both assume a specific
directory layout. Until now that assumption lived only in the template files and in the
discovery code, never written down. Every later piece of this work (the additive add
engine, the renderer, a project manifest) would otherwise re-guess where the domain root
is, where element modules live, where tests go, and which subtrees discovery must skip.
The layout is a contract because of how discovery works. Domain.init(traverse=True)
calls _traverse() (src/protean/domain/__init__.py), which:
- Treats the directory that holds the domain file (the module that constructs
Domain(...)) as the root. Whenroot_pathis not passed explicitly andDOMAIN_ROOT_PATHis unset,_guess_caller_path()resolves it to the directory of the file that called theDomainconstructor. - Scans one level deep, not recursively: it imports every
.pyfile directly in the root and every.pyfile directly in each immediate subdirectory (except the domain file itself), so each module executes and its decorators register elements. A module nested two levels below the root (for exampleexample/handlers/foo.py) is never imported. - Skips any immediate subdirectory that carries its own config file (
domain.toml,.domain.toml, orpyproject.toml). That file marks a separate boundary, which lines up with ADR-0003: oneDomainis one bounded context is one IR document.
Config resolution follows the same root. Config2.load_from_path(root_path) looks for
.domain.toml / domain.toml / pyproject.toml in the root and up to two parent
directories.
Two failure modes in the 0.17 line traced directly to a layout that the discovery path did
not expect. A generated example/__init__.py that re-exported from its own submodules created
partially initialized module cycles during traversal, so a real init(traverse=True)
crashed (#1316). And a logging.toml sat in the scaffold that no adapter read (#1315).
Both are fixed, but they are the reason to fix the layout in writing rather than leave it
implied.
The IR is the other half of the contract. Domain.to_ir() builds the IR's structural view
(clusters, contracts, flows, projections, the elements index, and config-derived domain
metadata) from the initialized in-memory domain, not from files on disk. It then runs an
advisory diagnostics pass that does re-parse source files, but that layer only annotates;
it derives no structure. So the layout's whole job is to make traversal deterministic: init
the canonical layout and the domain holds exactly the elements the developer wrote, and
nothing spurious (no test doubles, no re-export duplicates). The structural IR is a
projection of that domain.
Decision
We record the following as the canonical layout that protean new generates and that
discovery and the IR expect. A default project (package myproj) is shown below. The
example slice and its test are gated on the include_example copier flag (default on);
everything else is generated either way.
myproj/ # project root
├── pyproject.toml # packaging; domain config lives in domain.toml, not here
├── Makefile, Dockerfile*, docker-compose*.yml, nginx.conf, scripts/, .github/ # deploy scaffolding
├── src/
│ └── myproj/ # importable package == domain root
│ ├── __init__.py # empty
│ ├── domain.py # composition root: constructs Domain(name="myproj")
│ ├── domain.toml # domain config, co-located with domain.py
│ ├── example/ # one feature slice; generated only when include_example is on
│ │ ├── __init__.py # side-effect free (docstring only)
│ │ ├── aggregate.py
│ │ ├── commands.py
│ │ ├── events.py
│ │ ├── command_handlers.py
│ │ ├── projection.py
│ │ └── projectors.py
│ └── shared/ # cross-slice value objects, exceptions, logging helpers
│ ├── __init__.py # empty
│ ├── exceptions.py
│ ├── value_objects.py
│ └── logging.py
└── tests/
└── myproj/ # test tree, a sibling of src/, never traversed as domain code
├── conftest.py # session fixture boots the domain
├── test_smoke.py # always generated, so a fresh project never collects zero tests
├── domain/
│ └── __init__.py # placeholder package; the __init__ keeps the empty dir in git
├── application/
│ ├── __init__.py
│ └── test_example.py # generated only when include_example is on
└── integration/
└── __init__.py # placeholder package; the __init__ keeps the empty dir in git
The rules that make this a contract, not a suggestion:
-
One composition root per domain.
src/<package>/domain.pyconstructs the singleDomaininstance. Its directory is the discovery root. OneDomainis one bounded context is one IR document (ADR-0003). -
Config sits at the domain root.
domain.tomllives next todomain.pyinsrc/<package>/, whereload_from_pathfinds it first. -
Element modules live at most one directory below the domain root, one concept per module. A module directly in
src/<package>/, or directly in an immediate subpackage of it (hereexample/andshared/), is imported during traversal and registers via decorators. Discovery scans one level deep only, so a module nested deeper (for exampleexample/handlers/foo.py) is silently not discovered. Modules import the domain withfrom <package>.domain import <domain>and siblings with relative imports. -
Package
__init__.pyfiles are side-effect free. They carry a docstring at most. They do not re-export from submodules. Re-exports run during traversal and create partially initialized module cycles, which is exactly what brokeinit(traverse=True)in #1316. Discovery finds elements by importing each module directly, so the re-exports buy nothing and cost correctness. -
Tests live outside the domain root.
tests/is a sibling ofsrc/, so no test module is ever imported as domain code during traversal. Tests boot the domain themselves through a session-scoped fixture inconftest.py. -
A nested config file marks a boundary discovery skips. A subdirectory of the domain root that carries its own
domain.toml/.domain.toml/pyproject.tomlis treated as a separate boundary and is not traversed into. This is the escape hatch for a subtree that should not register into this domain.
Consequences
Later work builds against a written layout instead of re-reading the template. The add
engine knows to drop a new element module directly in src/<package>/ or in an immediate
slice subpackage (one level deep, so discovery finds it) and to leave __init__.py alone. A manifest knows the domain root is the directory of the
domain file and that tests/ is out of scope for discovery. The IR-derivation boundary is
explicit: the IR reflects the registered domain, so scaffold correctness is a discovery
question, not an IR question.
The generated project is opinionated about structure. A src/-layout package, a single
composition root, config beside the domain file, and tests as a sibling tree are all
fixed. A user who wants a flat layout or tests interleaved with source has to move away
from the scaffold and take responsibility for keeping discovery working (for example by
passing root_path explicitly or dropping a config file to fence off a subtree). That is
the intended trade: the default is correct by construction, and deviation is possible but
manual.
The __init__.py-must-stay-empty rule is easy to violate by habit, since re-exporting
from a package initializer is idiomatic Python elsewhere. Anyone hand-editing a generated
project, or writing a code generator that touches these files, has to know the rule. This
ADR is where it is written down, and the scaffold's example/__init__.py docstring states
it inline.
Alternatives Considered
Prune "redundant" template files as originally scoped. The issue first paired this ADR
with deleting template files the IR can derive. On inspection there are none: the structural
IR is computed from the registered domain model, not from files, so no shipped file
duplicates it. The Python element modules are the domain model the IR is built from, and the
rest is deployment scaffolding the IR never represents. (The one stranded file,
logging.toml, was an orphan that no adapter read, not an IR duplicate, and was removed
under #1315.) So the pruning half was dropped. Keeping the scope honest avoids deleting a
load-bearing file to satisfy a rule that does not apply.
A flat layout (no src/, modules beside domain.py at the project root). This works
for discovery but blurs the packaging boundary and makes it easy for tests/ or tooling
files to land inside the discovery root and be imported as domain code. The src/-layout
keeps the importable package, and therefore the discovery root, cleanly separated from
project-level files.
Tests under the domain root (src/<package>/tests/). Traversal would then import test
modules during discovery, executing test-time code and registering test doubles into the
real domain. Keeping tests a sibling of src/ removes that hazard entirely.
Re-exporting from package __init__.py for ergonomic imports. Convenient for callers,
but it is the direct cause of #1316: re-exports execute during traversal and create
partially initialized module cycles. Discovery does not need them, so the cost has no
matching benefit.
Config only in pyproject.toml. load_from_path supports it, but a dedicated
domain.toml beside domain.py keeps domain config next to the composition root and out
of the packaging file, and reads first in the lookup order. A generated project uses the
dedicated file; pyproject.toml config stays available for users who prefer it.