The extension boundary
Applies to The Fabrica v0.1.2. Documentation source:
1ee1bc9cb661, including post-release documentation corrections.
Every buyer who clones the factory will pull updates from us as long
as we keep shipping fixes. The hard problem is: how do they add their
own code without forking + losing the ability to git pull?
The factory’s answer is the _domain/ directory pattern. Buyer code
lives in _domain/ sub-directories under each module; factory code
lives one level up. Upstream does not overwrite the files you create
there, which shrinks the surface an update can collide with — it does
not make updates conflict-free.
The directories
backend/src/
├── api/routers/
│ ├── account.py # factory
│ ├── billing.py # factory
│ ├── admin.py # factory
│ └── _domain/ # ⬅ buyer
│ └── widgets.py
├── models/
│ ├── user.py # factory
│ ├── subscription.py # factory
│ └── _domain/ # ⬅ buyer
│ └── widget.py
├── services/
│ ├── email.py # factory
│ ├── paddle.py # factory
│ └── _domain/ # ⬅ buyer
│ └── widget_service.py
├── workers/tasks/_domain/ # ⬅ buyer's Celery tasks
├── prompts/_domain/ # ⬅ buyer's LLM prompts
└── scrapers/_domain/ # ⬅ buyer's scrapers (optional)
frontend/src/
├── components/
│ ├── ui/ # factory primitives (Button, Card, ...)
│ ├── providers/ # factory providers
│ └── _domain/ # ⬅ buyer's React components
└── app/[locale]/
├── (dashboard)/account/ # factory page
├── (dashboard)/admin/ # factory pages
└── (dashboard)/(_domain)/ # ⬅ buyer's dashboard pages (a route
# GROUP: `_domain/` alone is a Next
# private folder and never routes)Most _domain/ directories ship a README.md explaining naming +
skeleton; every one ships a .gitkeep. (data/seed/_domain/ is the one
with no README.)
Why the underscore prefix
- Python convention: leading underscore = “internal / extension”.
The factory’s own
_base.pyand_internal.pymodules use the same convention._domain/follows. - Git-pull-safe, for your files: the factory ships only
convention files — a
.gitkeep, and aREADME.mdin most — inside each_domain/, so a file you create there has no upstream counterpart to be overwritten by. Note what this does not say: upstream does write in those directories — it maintains those README files — so an update can still conflict there if you have edited or renamed them (see Naming the_domain/itself). - Sorts last:
_dsorts after letters inls. Factory files first; buyer dirs last. Reads top-down. - Searchable:
grep -r _domain backend/srcinstantly shows “this is buyer code”.
What this is NOT
This is not a plugin system. There’s no dynamic loading, no
manifest file, no priority resolution. _domain/ is a naming
convention + READMEs. Buyer code is regular Python / TypeScript
that imports the factory’s primitives + ships its own surfaces.
If a buyer needs runtime extensibility (load plugins by buyer name at startup), they layer that on top — the factory doesn’t ship it because most buyers don’t need it.
Auto-discovery (planned hooks, not yet shipped)
Open question for v0.2.0+: should _domain/ modules auto-mount?
# Hypothetical hook (not shipped):
for module in iter_modules("src.api.routers._domain"):
if hasattr(module, "router"):
app.include_router(module.router, prefix="/api/v1")Pros: zero-boilerplate mounting; buyers git pull factory updates
that include the auto-discovery without changing their own code.
Cons: import-time side effects + load-order subtleties; explicit
include_router calls are easier to debug (“why isn’t this route
returning?” → grep for the include call).
Explicit mounting is what ships today. Auto-discovery is a future recipe.
Naming the _domain/ itself
Some buyers asked: “Can I rename _domain/ to _acme/ to match my
brand?”. Technically yes — it’s just a directory. Trade-offs:
- Yes, rename: cleaner reading in your codebase. Cost: future
factory updates that touch
_domain/README files conflict; you have to grep + rename. - No, keep
_domain/: nameless, generic. Cost: less branded feeling.
The factory’s recommendation: keep _domain/ until you fork. If
you fork (no longer pulling updates), rename freely.
Test discipline
Tests for _domain/ code live alongside the factory tests in
backend/tests/. Don’t shadow factory test names; prefix with your
domain (test_widget_*.py).
backend/tests/
├── test_account_endpoints.py # factory
├── test_billing_api.py # factory
└── test_widget_*.py # ⬅ buyeruv run pytest runs all of them together. Factory test failures on
buyer code = buyer broke factory assumptions; buyer fixes their
side.
CI considerations
The factory’s CI config runs pytest -m "not integration"
ruff+mypy+ frontendtsc+vitest. Buyers inherit all of that — their_domain/code is linted/typed/tested by the same pipeline.
Strict mypy on buyer code is intentional. Loose typing in _domain/
infects the factory side via imports + makes the boundary blurry.
Receipts
- READMEs:
backend/src/api/routers/_domain/README.md,backend/src/models/_domain/README.md,backend/src/services/_domain/README.md,backend/src/workers/tasks/_domain/README.md,backend/src/prompts/_domain/README.md,backend/src/scrapers/_domain/README.md,frontend/src/components/_domain/README.md.
Provenance. Documentation source: 1ee1bc9cb6619c19f57766731e7884b37f515dc9 (main).
Extracted from docs/recipes/buyer-extension-model.md.
That commit is later than the release these pages describe: it carries documentation corrections made after thefabrica-v0.1.2 went out. Nothing that changes how the product behaves landed between the two, which is what makes naming that version honest.