ADR-0002: The framework absorbs per-module boilerplate¶
- Status: Accepted
- Date: 2026-10-09
- Deciders: Sandloom maintainers
- Supersedes: —
Context¶
A business module is supposed to be only its domain. In practice every module carried three pieces of framework plumbing, copied from the scaffold:
| File | Lines (linkly links) |
What it did |
|---|---|---|
tests/conftest.py (+ an app-root conftest.py) |
131 + 19 | load .env.test, guard against non-test databases, build/reset the schema, override get_db, build an httpx client, mint JWTs, reset the Redis client singleton |
migrations/env.py |
16 | name its own models module and table prefix for the Alembic runner |
__init__.py |
39 | a register(core) that mounted the router and looped over event schemas inside try/except |
The copies drifted: the examples still used drop_all and import sandloom.main
while the scaffold template had moved on to reset_schema and
load_composition_app(). The conftests imported kernel internals
(sandloom.core._internal.db_provider, sandloom.core.database, the Redis driver's
private _default_client) — the exact coupling module isolation forbids in
production code, tolerated in tests only because nothing checked it.
Decision¶
The framework owns this plumbing; modules declare, conventions derive.
- Test harness = an explicit pytest plugin.
sandloom.core.testingbecomes a package (all existing names stay importable) withplugin.py, activated by the application once:addopts = ["-p", "sandloom.core.testing.plugin"]. It loads.env.testinpytest_load_initial_conftests— before any conftest can importsandloom.core.config.settings, which snapshots the environment — so the app-root conftest disappears as well. Fixtures:sandloom_app,db_engine,db,client,make_headers,user_headers, back-compat aliasestest_db/async_client/test_engine, and per-test pool release. Nopytest11entry point: installing the framework must not change the behaviour of any other suite, including the framework's own. - Framework-tree modules (scaffolded into
src/sandloom/, whose suite does not load the plugin, and wherepytest_pluginsis not allowed in a non-root conftest) get a one-line conftest:from sandloom.core.testing.fixtures import *. - Root cause over workaround. The Redis cache driver's clients are loop-aware (rebuilt when the running loop changes), so no harness needs to poke a driver singleton.
- Migrations by convention.
run_module_migrations(__file__)derives the package,<pkg>.modelsand<module>_from the env.py location; keywords override. - Declarative registration.
register = define_module(__name__, ...)mounts<pkg>.api's router, registers<pkg>.events.EVENT_SCHEMAS, callssetup(core)for anything custom and returns the descriptor. Identical schema re-registration is a no-op (it already was; now pinned), so notry/except. A hand-writtendef register(core)keeps working. - Enforced. A new archcheck rule
test-imports(run bysandloom checkand the framework's architecture tests) forbids module tests from importingsandloom.core._internal,sandloom.core.sql_alchemy,sandloom.core.database,sandloom.core.drivers. Scaffold-output tests pin the sizes (conftest absent or ≤ 3 lines, env.py ≤ 3,__init__≤ 8 non-blank lines) and that a freshsandloom newproject + scaffolded module passes its own tests.
Consequences¶
- linkly's
linksmodule boilerplate went from 205 lines to 12 (conftests 150 → 0, env.py 16 → 3,__init__39 → 9). - Harness fixes land once, in the framework, for every app.
- The bundled
identity/tenancy/billingsuites keep their bespoke conftests (Redis/SSO fixtures, ambient recording UoW, PSP fakes); they passtest-importswithout allowlist entries (the legacy debt was paid off on 2026-10-21). - An application that predates this must add the
-paddopt once; its existing conftests keep working (fixture names are compatible, and a local fixture overrides a plugin fixture of the same name). define_moduleis convention-bound: a module whose router lives elsewhere passesrouter=, one without anevents.pysimply has no schemas.
Alternatives rejected¶
pytest11entry point (auto-activation): changes unrelated suites on install and would silently alter the framework's own suite.- Conftest templates kept, just shorter: still copies that drift, and a
non-root conftest cannot load the env early enough for
settings. - Frame-inspection-only
define_module(): works, but the explicitdefine_module(__name__)is greppable and robust; inference stays as a fallback.