Framework overview
simple_module_python is a modular-monolith framework. The framework is framework/core, framework/db, and framework/hosting — three packages that provide the module system, DB session management, and the FastAPI app builder. Everything else is a plugin module.
framework/
core/ # ModuleBase, discovery, event bus, diagnostics
db/ # create_module_base, mixins, session lifecycle
hosting/ # create_app, middleware pipeline, settingsThe framework has no knowledge of any specific plugin module. Diagnostic SM009 fires as an error if framework code imports from anything under modules/*.
Boot sequence
What actually happens when you run uvicorn host.main:app:
create_app(settings)is invoked (inhost/main.pyvia FastAPI's lifespan).- Framework singletons are constructed:
Settings,DatabaseState(engines per provider),EventBus,MenuRegistry,PermissionRegistry,FeatureFlagRegistry,HealthRegistry,I18nRegistry. They are bundled into a frozenServicesdataclass and attached toapp.state.sm. - Discovery —
discover_modules()reads Python entry points under thesimple_modulegroup, imports each one, validates it's aModuleBasesubclass with a non-nullmeta, and topologically sorts byModuleMeta.depends_on. - Lifecycle hooks run in sorted order. For each module, in this order:
register_settings→register_menu_items→register_permissions→register_feature_flags→register_event_handlers→register_health_checks→register_exception_handlers→register_middleware→register_routes(api_router, view_router). - Middleware is installed — framework middleware first, then whatever modules registered. See Middleware pipeline.
- Routers mount —
api_routerat/api,view_routerat/. Each module's sub-routers were attached viaregister_routes. - Lifespan
on_startup— each module's asyncon_startupruns in dependency order. This is where background workers, warm caches, or remote-service health probes start. - Migration-head check (dev only) — compares
alembic_versionto the migration head and logs a warning if behind. In production this is a hard failure (SM010). Result goes onapp.state.migration. - App is ready to serve.
On shutdown, on_shutdown hooks run in reverse order.
Strict mode vs. lenient mode
Discovery and diagnostics behave differently based on SM_ENVIRONMENT:
| Environment | Mode | Behavior |
|---|---|---|
development (default) | Lenient | Entry-point load errors are logged; the module is skipped. SM001 warnings printed. |
test, testing | Lenient | Same as development, so fixtures can load partial worlds. |
Anything else (e.g. production) | Strict | Any missing meta, duplicate name (SM008), framework→plugin import (SM009), or DB drift (SM010) fails boot. |
The distinction exists so developer ergonomics aren't gated on fixing every warning, but production deployments can't silently run with broken modules.
The Services container
Framework singletons live on app.state.sm, a frozen dataclass defined in simple_module_hosting.services:
@dataclass(frozen=True)
class Services:
settings: Settings
db: DatabaseState
event_bus: EventBus
menu_registry: MenuRegistry
permissions: PermissionRegistry
feature_flags: FeatureFlagRegistry
health_registry: HealthRegistry
i18n_registry: I18nRegistry
inertia_config: InertiaConfig
modules: tuple[ModuleBase, ...]Read from a request with request.app.state.sm.<field>. Do not attach new attributes to app.state for framework-owned state — that's what Services is for. Two attributes are intentionally kept outside:
app.state.inertia_dependency— request-scopedDependsfactory from fastapi-inertia.app.state.migration— the dev-only boot-time migration check result.
Module-owned state goes on app.state.<module_lower> (a per-module dataclass you define). See Settings & app.state.
Framework vs. plugin boundary
Framework code must never import from modules/*. Diagnostic SM009 enforces this at CI time. To invert dependencies when framework code needs per-module behavior, register a callback from the module:
# Framework defines a registry
class PrincipalSerializerRegistry:
def register(self, fn: Callable[[User], dict]) -> None: ...
# Module registers during register_settings
class UsersModule(ModuleBase):
def register_settings(self, app: FastAPI) -> None:
app.state.sm.inertia_config.register_principal_serializer(
serialize_user
)This is how the auth.user shared prop is built: framework middleware calls whatever serializer the users module registered — without importing users.
What's next
- Discovery & entry points — how modules are installed and found.
- Lifecycle hooks — the 10 hooks in call order with examples.
- Middleware pipeline — execution order and how to slot your own in.
- Settings & app.state — framework vs. module state.