What a module is¶
A module is one Python package that owns one slice of the business: its tables, routes, events, contracts and tests. Orders, billing and notifications would each be a module.
Modules are atomic and isolated: you can work inside one without reading or breaking any other. That is what keeps a change by a person or an agent contained to one package, and what lets one codebase run as a monolith or as separate services by configuration alone.
What a module looks like¶
<module>/
__init__.py # register = define_module(__name__, version="0.1.0")
api.py # router = module_router(): the routes
services.py # use cases; db() is the session of the running unit
models/ # tables; their names start with <module>_
schemas.py # pydantic request and response shapes (no ORM)
events.py # Event classes of the module's own channels
consumers.py # views of other modules' channels and @on handlers
contracts.py # @contract providers
sagas.py # sagas the module owns
projections.py # read models the module builds
tasks.py # background tasks
migrations/ # the module's own migration chain
tests/ # the module's tests
Only __init__.py is required. A larger module may make any of these a
package; discovery walks it.
Create one with uv run sandloom add module <name>; Add a module
walks through it. The application finds the new package by itself, so nothing else needs
editing.
What you get by default, and what you declare¶
| You write | Sandloom does for you |
|---|---|
register = define_module(__name__) in __init__.py |
imports the module's conventional submodules and registers everything it finds there (routes, events, consumers, contracts, sagas, projections, tables) |
tables in models/ (TenantScoped or Base) |
adds them to the module's own migration chain; sandloom migrate new renders the revision with row-level security |
router = module_router() in api.py |
prefix /api/v1/<module>, tags, authentication and tenant binding per route |
class E(Event, channel="m.e.v1") |
registers the schema of your own channel, snapshots it, exports it for other repositories |
@on(E) |
a durable consumer with exactly-once apply on the envelope id |
@contract("m.x.v1") |
registers the provider; sandloom generate writes the typed stub and DTO copies callers import |
Contract(REF) at module level |
derives the module's consumed contracts (consumes) |
| nothing | the module list: an application loads every subpackage of <app>.modules that defines register |
What a module may import¶
A business module and its tests import only:
- public
sandloom.sdkmodules: the beginner names fromsandloom.sdk, every other name from its themed module (sandloom.sdk.web,sandloom.sdk.data, ...; see the Python API); - generated contract stubs and DTO copies (
sandloom.shared.contracts.*, or the application's shared package<app>.shared/CONTRACT_DTO_PACKAGES); - its own package.
A module never imports another business module (an installed module package
such as sandloom_identity included), a kernel internal, or a
private sandloom.sdk._* layer. Any spelling counts: absolute, relative,
importlib, __import__, getattr on a module, sys.modules. A module also
never reaches a kernel handle by attribute (.event_bus, .infra,
.service_registry, request.app.state.core, .raw_client()), and imports
no backend SDK (Redis, boto3, Celery), driver or sandloom.driverkit directly:
infrastructure comes from
capabilities (Capabilities and infrastructure).
Business modules carry no function-local imports
(SANDLOOM-C021); the one exception is a
marked optional dependency (# sandloom: optional-import <reason>).
How modules talk to each other¶
Through events when something has happened, and through contracts when one module needs an answer now, never through an import. Choose how modules talk compares every option.
Other modules' rows are referenced by plain UUID, never by a foreign key. A contract call runs in the provider's own unit on the provider's database, locally or over internal RPC: local equals remote.
Conventions inside a module¶
- Layers. SQLAlchemy and ORM models belong to
models/andservices.py(or aservices/package). Routes (api.py) andschemas.pystay persistence-free: a route calls a service and returns a pydantic DTO (SANDLOOM-C009, SANDLOOM-C010, SANDLOOM-C011). - Event channels are versioned:
<module>.<entity>.<verb>.vN(Events). - Bound list endpoints: cap
limitwithQuery(le=...). - Ids and timestamps come from the framework:
default=new_idandcreated_at: Mapped[CreatedAt](details). - Startup work goes through
core.add_startup_task(fn)insetup;critical=Truewhen the process must not serve traffic if it fails.
What breaks it¶
| Code | What it catches |
|---|---|
| SANDLOOM-C001 | an import of another business module |
| SANDLOOM-C002 | an import outside the public API |
| SANDLOOM-C003, SANDLOOM-C004, SANDLOOM-C005 | a raw provider, a backend SDK, a kernel handle |
| SANDLOOM-C009, SANDLOOM-C010, SANDLOOM-C011 | endpoints outside the api layer, ORM in endpoints, ORM in schemas |
| SANDLOOM-C021 | a function-local import |
| SANDLOOM-B001, SANDLOOM-B006 | a module that fails to load or registers something invalid |
| SANDLOOM-T009 | an advanced name in generated code or an example without a marker |
sandloom check --list-rules lists every static rule; sandloom test runs them with
the architecture tests and the import contracts.
Guarantees, precisely
- Imports are restricted to
sandloom.sdkpublic modules, the DTO packages and the module's own package (G-MODULE-02, G-MODULE-13). - Discovery keeps today's plain-router rule; a refused module registers nothing (G-MODULE-14, C-MODULE-04).
- L1 registration equals the hand-written L2 registration, and every L1 dependency is a public L2 name (G-SDK-03, G-SDK-05).
- Generated code and examples stay in the beginner tier (G-SDK-04).
- Importing
sandloom.sdkreads no environment and performs no I/O (G-SDK-01). - An application derives its module list (G-MODULE-19).
The ledger with the enforcing tests: Guarantees & capabilities.
Inspect before changing a module¶
Use module context to gather scoped rules, SDK and wire interfaces, and the declared architecture graph to inspect contract/event connections. Both commands can be invoked by an agent and read their index without booting the app.
Go deeper¶
- Module internals: the beginner names, the API levels, discovery, module packages and generated files.
- ADR-0002: the framework absorbs module boilerplate