Skip to content
Menu

Documentation

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 (Event subclasses, @on handlers, @contract classes) 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 APIRouter bound to <pkg>.api.router, else every APIRouter among api.__all__ (or its public names), is mounted. module_router() routers anywhere in the api submodules join them.
  • Tables. <pkg>.models is 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= and events= 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.modules entry-point group, for example identity = "sandloom_identity" in its pyproject.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.notes is notes). sandloom.core.descriptors.module_names holds the rule.
  • Selection. SANDLOOM_MODULES, MODULE_REQUIRED and deploy profiles name a module by entry-point name (identity) or import path (sandloom_identity). sandloom.main:app composes every installed entry point (sorted) when SANDLOOM_MODULES is unset; a generated app lists its modules. MODULE_LOAD_ENTRYPOINTS=true makes 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.identity next to sandloom-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.shared and ship with sandloom (SANDLOOM-C012: every caller process imports them).

To ship a module of your own:

  1. Give it its own distribution with one sandloom.modules entry point (<name> = "<import_package>"). Lay the repository out like the framework tree: top-level module packages under src/, modules_package: "" in its architecture_rules.yml, so sandloom check applies.
  2. Depend on a compatible framework range, sandloom>=0.7.0a7,<0.8, never an exact pin, and on no other module package.
  3. Author its contract DTOs in its own schemas module and ship the stubs and DTO copies sandloom generate writes in a package every caller process imports (SANDLOOM-C012), listed in the app's CONTRACT_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.

Design rationale