Framework Conventions
Short reference for the invariants module authors rely on. When in doubt, read the linked source — it's the source of truth; this document exists so you don't have to reverse-engineer it.
Module structure
modules/<name>/
├── pyproject.toml # entry point: simple_module = "<name>.module:<Class>Module"
└── <name>/
├── __init__.py
├── module.py # ModuleBase subclass with meta = ModuleMeta(...)
├── models.py # SQLModel tables (optional)
├── service.py # business logic
├── deps.py # FastAPI dependencies
├── contracts/ # SQLModel DTOs (public surface)
├── endpoints/
│ ├── api.py # REST endpoints (JSON)
│ └── views.py # Inertia view endpoints
└── pages/ # *.tsx — auto-discovered by ViteScaffold a fresh module with smpy create-module <name> --dest modules/<name> — it generates a working subset (module.py, services.py, settings.py, endpoints/api.py, an empty pages/, plus packaging files); add models.py, deps.py, contracts/, endpoints/views.py, and locales/ as your module needs them. Then run uv add ./modules/<name> to register it on your app.
ModuleMeta
Every ModuleBase subclass must declare a meta class attribute:
class OrdersModule(ModuleBase):
meta = ModuleMeta(
name="Orders", # PascalCase, unique
route_prefix="/api/orders", # mounted on the API router
view_prefix="/orders", # mounted on the view router
depends_on=["Products"], # strict load order
version="1.0.0",
)nameis unique across the app — it's the table-name prefix, the Inertia component namespace, and the diagnostic reporter name.depends_onexpresses a hard ordering requirement; the framework topo-sorts modules and invokes lifecycle hooks in that order.- Missing or invalid
metafails the boot in production (strict discovery) and logs aSM001warning in development.
Discovery & entry points
Modules are registered via [project.entry-points.simple_module] in the module's pyproject.toml:
[project.entry-points.simple_module]
orders = "orders.module:OrdersModule"The framework calls discover_modules() at app-build time. No manual wiring in the host.
In production (SM_ENVIRONMENT != development) discovery runs in strict mode: any entry-point load failure or structural error (missing meta, wrong base class) raises InvalidModuleError at boot. In development, errors are logged and the module is skipped.
Middleware pipeline
Starlette's add_middleware is LIFO — the last middleware added is the first executed on a request. The framework installs middleware in this order inside create_app:
# Added last → first executed
CorrelationIdMiddleware
RequestLoggingMiddleware
SecurityHeadersMiddleware
SessionMiddleware
<module-registered middleware> # each module's register_middleware()
TenantMiddleware (if multi_tenant=True)
LocaleMiddleware
InertiaLayoutDataMiddleware
# Added first → last executedExecution order on a request:
CorrelationId → RequestLogging → SecurityHeaders → Session
→ <modules> → Tenant → Locale → InertiaLayoutData → appModule-registered middleware ordering
When two modules at the same dependency tier both call app.add_middleware(...) in their register_middleware hook, the order is governed by topological sort and the LIFO rule: the module that sorts later wraps its middleware outermost (runs first).
If you need a specific relative order, express it with ModuleMeta.depends_on. Don't rely on alphabetical names.
Settings
Framework settings live on Settings (simple_module_hosting.settings), prefix SM_:
SM_DATABASE_URL, SM_ENVIRONMENT, SM_SECRET_KEY, SM_VITE_DEV_URL,
SM_DEBUG, SM_LOG_LEVEL, SM_LOG_FORMAT, SM_MULTI_TENANT, SM_TENANT_HEADERFramework state
Framework singletons live on app.state.sm, a frozen Services dataclass populated once at boot. Consumers read request.app.state.sm.<field> — never raw app.state attributes for framework-owned state.
Fields: settings, db, event_bus, menu_registry, permissions, feature_flags, health_registry, public_routes, i18n_registry, inertia_config, modules.
Two attributes are intentionally kept outside Services:
app.state.inertia_dependency— request-scopedDependsfactory from fastapi-inertia.app.state.migration— dev-only boot-time check result, set in lifespan.
Module settings
Module settings should:
- Use a per-module prefix:
SM_<MODULE>_*(e.g.SM_USERS_ALLOW_SIGNUP). - Be stored inside a module-owned dataclass at
app.state.<module_lower>duringregister_settings(app). SM012diagnostic fires ifregister_settingsis overridden but noapp.state.<module_lower>entry is added.
class UsersModule(ModuleBase):
def register_settings(self, app: FastAPI) -> None:
from users.settings import UsersSettings
from users.state import UsersState
app.state.users = UsersState(settings=UsersSettings())Database
Models
SQLModel is the project-wide standard for every model — both DB tables and DTOs. Table classes declare table=True and inherit the per-module Base; DTOs are plain SQLModel subclasses. No Pydantic BaseModel and no SQLAlchemy DeclarativeBase + Mapped[...]/mapped_column in module code.
# modules/orders/orders/models.py
from simple_module_db.base import create_module_base
from simple_module_db.mixins import AuditMixin
from sqlmodel import Field
Base = create_module_base("orders")
class Order(Base, AuditMixin, table=True):
__tablename__ = "orders_order"
id: int | None = Field(default=None, primary_key=True)
name: str = Field(max_length=200)
# modules/orders/orders/contracts/schemas.py
from pydantic import ConfigDict
from sqlmodel import Field, SQLModel
class OrderOut(SQLModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
class OrderCreate(SQLModel):
name: str = Field(min_length=1, max_length=200)Per-module Base
from simple_module_db.base import create_module_base
Base = create_module_base("orders")create_module_base returns a SQLModel base bound to its own MetaData, but all modules share the host's single schema (same layout on Postgres and SQLite). Prefix __tablename__ with the module name to avoid collisions (orders_order).
Mixins
AuditMixin—created_at,updated_at,created_by,updated_by(auto-populated from the current user in listeners).SoftDeleteMixin—is_deleted,deleted_at,deleted_by.delete()converts to soft-delete;SELECTauto-filters. Bypass withstmt.execution_options(include_deleted=True).MultiTenantMixin—tenant_id. Auto-populated on insert;SELECTauto-filters whencurrent_tenant_idis set.VersionedMixin—version, auto-incremented on update.
Session lifecycle (get_db)
Each request opens one session:
- Commit fires on success only if the session has pending writes (
has_writesflag set byafter_flushlistener, or live.new/.dirty/.deleted). - Read-only requests exit via rollback — cheaper, and keeps the session out of write-side profiling.
- Exceptions always rollback.
Service code should not call session.commit() directly. Flush for intermediate reads if you need DB-assigned values, then let the dependency commit.
Inertia
Page keys
inertia.render("<ModuleName>/<PageName>", ...) maps to modules/<name>/<name>/pages/<PageName>.tsx.
<ModuleName>is the PascalCase of the module directory:blog_posts→BlogPosts.- Host-level pages under
client_app/pages/<PageName>.tsxrender with just<PageName>as the key (e.g.inertia.render("Landing", ...)). - Mismatched keys fire
SM003(orphan page) andSM004(phantom render) at diagnostic time.
Shared props
InertiaLayoutDataMiddleware populates request.state.inertia_shared with:
auth.user,auth.isAuthenticated,auth.permissions(expanded from roles).menus— grouped byMenuSection(sidebar, adminSidebar, navbar, userDropdown), role-filtered.Sidebar
orderfollows banded ranges so installed modules sort into coherent groups:Band Purpose Built-in modules 10–99Content / domain Dashboard (10), Products (20), Datasets (30), Files (40) 100–199Administration Users (100), Feature Flags (110), Background Tasks (120) 200+System Settings (200) Pick a band by audience, leave gaps of ~10 between siblings, and put module-specific user-dropdown items in the
900+range (Profile=990, Logout=999).Sidebar items can also set
group="<Label>"on theMenuItemto render under a group header. The frontend clusters consecutive items with the same group label and prints the label as a section heading; the group's position is set by the lowest-orderitem that joins it. Built-in groups areContent,Administration, andSystem. Items with nogroup(the default) render flat — Dashboard intentionally stays ungrouped above the headed groups.i18n— active locale and translation bundle.
The framework does not know the shape of auth.user. The auth module registers a principal_serializer: Callable[[UserContext], dict] on app.state.principal_serializer during register_settings(app); the middleware calls it with request.state.user to build the auth.user payload. Without a registered serializer, auth.user is None even when a user is authenticated.
Use InertiaDep from simple_module_hosting.inertia_deps — it attaches the shared data automatically.
Permissions
Modules declare permissions in register_permissions(registry) grouped by a name prefix:
registry.add_group("Orders", [
"orders.view", "orders.create", "orders.edit", "orders.delete",
])Enforce with the RequiresPermission dependency:
@router.post("/", dependencies=[Depends(RequiresPermission("orders.create"))])
async def create_order(...): ...DEFAULT_ROLE_PERMISSIONS in simple_module_hosting.permissions ships only admin: ["*"]. Host apps configure their own role → permission map; the framework does not know about plugin permission strings.
Authentication extension points
The auth module exposes a principal-resolver chain on app.state.auth.principal_resolvers — a list of async callables that AuthMiddleware consults after the session-cookie path. Use it to add non-cookie credential sources (Personal Access Tokens, API keys, JWTs) without forking the middleware. See docs/framework/principal-resolvers.md for the contract, ordering rules, and a worked Bearer-token example.
Auth Provider Contract
The framework supports swappable authentication backends. Exactly one auth provider module must be installed — either simple-module-users (local credentials + OAuth) or simple-module-keycloak (Keycloak OIDC). Both implement the AuthProvider protocol from auth.contracts.provider.
Module authors never import from users or keycloak directly. Use only:
from auth.deps import CurrentUser, require_permissionfrom auth.contracts.schemas import UserContext
The AuthMiddleware (in auth/middleware.py) delegates to the active provider's resolve_user() method, then falls through to the principal-resolver chain. API paths (/api/*) receive 401 JSON when unauthenticated; view paths receive a 302 redirect to the provider's login URL.
Boot-time diagnostic SM020 fails if multiple auth providers are installed. SM021 warns if none is installed.
Public routes (anonymous access)
To expose a route without a session — a read-only STAC / OGC API, a TileJSON endpoint, an inbound webhook — a module overrides register_public_routes:
def register_public_routes(self, registry):
registry.add_prefix("/api/gis/stac")
registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"})Rules are method-aware, so a GET read route can be exempted while sibling POST/PATCH mutations under the same prefix stay gated. The host aggregates every module's rules into one PublicRouteRegistry (plus host-level SM_AUTH_PUBLIC_PATHS prefixes) and publishes it at app.state.public_routes, which AuthMiddleware consults on every request. See docs/framework/public-routes.md for match kinds and resolution order.
Events
Base class: Event from simple_module_core.events. Subclass per domain event:
@dataclass
class OrderPlaced(Event):
order_id: int
total: DecimalSubscribe in register_event_handlers(bus):
def register_event_handlers(self, bus: EventBus) -> None:
bus.subscribe(OrderPlaced, self._on_order_placed)Publish from anywhere with a bus handle:
await bus.publish(OrderPlaced(order_id=42, total=Decimal("99")))Dispatch keys on the exact event class (module.qualname), not its MRO — subscribing to a base Event class does not receive subclass events. Subscribe to the concrete type you want.
Diagnostic codes
| Code | Level | Trigger |
|---|---|---|
| SM001 | ERROR | Module missing meta |
| SM003 | WARNING | pages/<name>.tsx exists but no matching inertia.render() call |
| SM004 | WARNING | inertia.render("<Module>/<name>") but no matching .tsx |
| SM007 | INFO | Module defines no register_* hooks |
| SM008 | ERROR | Duplicate module name / schema prefix conflict |
| SM009 | ERROR | Framework package directly imports from a plugin module |
| SM010 | ERROR | DB revision behind migration head |
| SM011 | WARNING | Module table not in migration history |
| SM012 | WARNING | register_settings overridden but nothing added to app.state |
| SM013 | WARNING | Locale file missing for a supported locale |
| SM014 | WARNING | Non-default locale missing keys present in the default |
| SM015 | WARNING | Non-default locale has keys not in the default |
| SM016 | ERROR | Locale JSON invalid or contains non-string leaves |
| SM017 | WARNING | Module ships pages/*.tsx but is missing package.json / tsconfig.json |
| SM018 | WARNING | Inertia router.{post,patch,put,delete}() in a page targets a JSON /api/* endpoint |
| SM019 | WARNING | Module registers view routes but no menu items or permissions (unreachable in UI) |
| SM020 | ERROR | Multiple auth provider modules installed |
| SM021 | WARNING | No auth provider module installed |
Diagnostics run automatically at boot — warnings print to stderr in dev, errors abort startup in production. There's no separate "run diagnostics" step in a scaffolded app: just start the server.
Internationalization
Modules ship translations as JSON under <package>/locales/<lang>.json and declare them via ModuleBase.locale_dirs():
import importlib.resources
from pathlib import Path
class OrdersModule(ModuleBase):
def locale_dirs(self) -> dict[str, Path]:
return {"orders": Path(str(importlib.resources.files(__package__) / "locales"))}Add this method (and a matching locales/en.json) yourself — the smpy create-module scaffold does not emit a locales/ directory.
Key naming
Keys are namespaced by the module and hierarchical by area. Convention: <namespace>.<area>.<string> — e.g. orders.browse.title. Use snake_case for leaves. Nested JSON objects are flattened at boot — {"browse": {"title": "X"}} under namespace orders becomes orders.browse.title at runtime.
Interpolation
Placeholders use {name} syntax, consistent between frontend and backend:
{ "greeting": "Hello, {name}" }t('orders.greeting', { name: user.name }) // frontendt.t("orders.greeting", name=user.name) # backendMissing placeholders are left verbatim ("Hello, {name}") rather than raising.
Pluralization
Suffix keys with CLDR categories (_zero, _one, _two, _few, _many, _other); only _other is required. Pass count as a param:
{
"items_one": "{count} item",
"items_other": "{count} items"
}t('orders.items', { count: items.length })Backend uses Babel's CLDR plural rules; frontend uses i18next's Intl.PluralRules. Both follow the same CLDR categories, so behavior matches across the stack.
Validation messages
Zod schemas with translated messages must be constructed inside a hook so they resolve against the active locale:
export function useProductSchema() {
const { t } = useT();
return z.object({
name: z.string().min(1, t('products.validation.name_required')),
});
}Do NOT declare const schema = z.object({ ... t('...') }) at module scope — it will resolve against whatever locale was active at first render, forever.
Host and shared-package strings
- Host strings (landing page, error page) live in
host/locales/and are namespacedhost.*. - Shared UI strings (
packages/ui/) live inpackages/ui/locales/, namespacedui.*. - Both are auto-discovered at boot alongside module contributions.
Supported locales
Configure via env:
SM_I18N_DEFAULT_LOCALE=en
SM_I18N_SUPPORTED_LOCALES=en,es,de
SM_I18N_COOKIE_NAME=localeThe default locale must be in the supported list (enforced by a pydantic validator).
Locale resolution order
Per request, LocaleMiddleware picks the active locale in this order:
- Cookie named by
SM_I18N_COOKIE_NAME(defaultlocale), validated againstSM_I18N_SUPPORTED_LOCALES. Accept-Languageheader, with q-value parsing and longest-prefix match (es-MX→es).SM_I18N_DEFAULT_LOCALE.
The active locale lands on request.state.locale. The <LocaleSwitcher /> component POSTs to /i18n/set-locale, which sets a 1-year cookie and redirects back.
Diagnostics
App boot runs I18nDiagnostics against every module's declared locale dirs. See codes SM013–SM016 in the table above. Warnings are printed in dev; errors fail the boot in production.