Module internals¶
You do not need this page to write a module. It explains what happens underneath What a module is, for when you drop below the generated code, ship a module as its own package or read a discovery error.
The beginner tier¶
Twenty-nine names cover CRUD, events and cross-module calls. Generated code
and the examples use only these (sandloom test checks it,
SANDLOOM-T009); any other name is advanced
and its import line says why: # sandloom: advanced <reason>.
| Need | Names (from sandloom.sdk import ...) |
|---|---|
| a module | define_module |
| tables | Base, TenantScoped, db, new_id, CreatedAt |
| routes | module_router, public, CurrentUser |
| events | Event, on |
| contracts | contract, command, Contract |
| authorization | require, Subscribed, SubscriptionActive, Credits, OrgRole, Owner, authorize_resource |
| effects | record_audit, on_commit |
tests (sandloom.sdk.testing) |
client (fixture), as_user, published, deliver, fake_contract, fake_access_fact |
Three API levels¶
| Level | What it is | Who uses it |
|---|---|---|
| L1, declarative | define_module discovery, module_router, Event and .publish(), on, contract, command, Contract, ContractRef, OptionalContract, typed saga steps, Projection, ProjectionView |
module authors and the generators |
| L2, primitives | the advanced names of the sandloom.sdk modules: ModuleAPI, publish_event, contract_method, ContractRequirement, DbSession, idempotency_guard, ... |
modules with a need L1 does not cover; L1 itself |
| L3, kernel | the framework internals | kernel contributors only |
L1 is built only from L2: every name L1 uses is a public L2 name, and a
module registered through L1 produces exactly the registration a hand-written
L2 register would. So a module may drop to L2 for one feature without
losing any guarantee. The guarantees (deny-by-default, sessions, re-checks,
idempotency, write admission, labels) live in L3 and apply to every router,
including a plain FastAPI APIRouter.
The L2 form of a module
define_module builds this function. A module may write it by hand:
from sandloom.sdk.module import ContractRequirement, ModuleAPI, ModuleDescriptor # sandloom: advanced L2 module
def register(core: ModuleAPI) -> ModuleDescriptor:
core.include_router(router)
core.register_event_schema("notes.note.created.v1", "NoteCreated", schema)
core.register_event_consumer(
"stats.counter.reset.v1", handler, name="h", bind_tenant_from="tenant_id"
)
core.register_service("notes.lookup.v1", Lookup)
return ModuleDescriptor(
name=__name__,
version="0.1.0",
consumes=[ContractRequirement("stats.quota.v1", methods=("may_create_note",))],
)
Discovery¶
define_module(__name__) builds the module's register(core). When the
application loads the module, it imports these submodules in this order, if
they exist: models, api, events, consumers, contracts, sagas,
projections, tasks. A package is walked recursively, sorted by name.
- What is collected. Classes and functions (
Eventsubclasses,@onhandlers,@contractclasses) belong to the module when they are defined inside its package. Instances (module_router()routers,Contract(...)references,Saga,Projection) record the package that constructed them. Aliases and re-exports are counted once; objects owned by another package are ignored. - Misplaced declarations. An L1 object outside the conventional submodules is refused with the module to move it to (SANDLOOM-B006).
- Plain routers keep their rule. The
APIRouterbound to<pkg>.api.router, else everyAPIRouteramongapi.__all__(or its public names), is mounted.module_router()routers anywhere in theapisubmodules join them. - Tables.
<pkg>.modelsis imported first. A mapped class whose module is inside the package belongs to the module; its table name starts with<module>_. - Atomic. Discovery fills a registration draft, validates it, then applies it. A failure applies nothing and names the original error (SANDLOOM-B001).
setup(core)stays for what has no declaration: startup and shutdown tasks, claims contributors, planner operations.router=andevents=override discovery;consumes=adds to the derived requirements, never weakening them.
The module list is discovered too. An application loads every subpackage of
<app>.modules whose __init__.py defines register (found statically, so
adding a module edits nothing else); installed module packages are found by
their entry point (Module packages). SANDLOOM_MODULES
still selects a subset, which is how one codebase deploys as several services
(Deployment).
Module packages¶
A module can also ship as its own distribution. Identity, tenancy and billing
do: sandloom-identity, sandloom-tenancy and sandloom-billing, import
packages sandloom_identity, sandloom_tenancy and sandloom_billing
(packages/README.md in the repository lists the distributions).
- Entry point. A module distribution registers itself in the
sandloom.modulesentry-point group, for exampleidentity = "sandloom_identity"in itspyproject.toml. - Module name. The entry-point name is the module name: it keys the
tables (
identity_*), the migration chain (alembic_version_identity), the event channels and contract names (identity.*). Any other module's name is the last segment of its import path (app.modules.notesisnotes).sandloom.core.descriptors.module_namesholds the rule. - Selection.
SANDLOOM_MODULES,MODULE_REQUIREDand deploy profiles name a module by entry-point name (identity) or import path (sandloom_identity).sandloom.main:appcomposes every installed entry point (sorted) whenSANDLOOM_MODULESis unset; a generated app lists its modules.MODULE_LOAD_ENTRYPOINTS=truemakes a loader also load every installed module package. - Ambiguity. One name has one provider. The loader refuses, before
anything loads, a requested module whose name two installed packages
register, or that an installed package and an app-local module both carry
(
app.modules.identitynext tosandloom-identity) (SANDLOOM-B020). The message names both providers: uninstall the package or remove (rename) the local module. - Isolation. An installed module package is another module: an app
module importing it, in any spelling, is
SANDLOOM-C001. Talk to it
through its events and contracts. The standard modules' contract stubs, the
generated copies of their DTOs and their event schemas live in
sandloom.sharedand ship withsandloom(SANDLOOM-C012: every caller process imports them).
To ship a module of your own:
- Give it its own distribution with one
sandloom.modulesentry point (<name> = "<import_package>"). Lay the repository out like the framework tree: top-level module packages undersrc/,modules_package: ""in itsarchitecture_rules.yml, sosandloom checkapplies. - Depend on a compatible framework range,
sandloom>=0.7.0a7,<0.8, never an exact pin, and on no other module package. - Author its contract DTOs in its own
schemasmodule and ship the stubs and DTO copiessandloom generatewrites in a package every caller process imports (SANDLOOM-C012), listed in the app'sCONTRACT_DTO_PACKAGES; an app composing the module also generates its own copies into<app>.shared.
Then pip install it next to an app and add its name to SANDLOOM_MODULES.
Generated files¶
sandloom generate writes each module's snapshots and the contract stubs, DTO
copies and event schemas that callers import. These generated files are the one
stated exception to "a module edits nothing outside its package".
Generate contracts and snapshots lists
every file and the compatibility check that runs before writing.