Middleware pipeline
Starlette's app.add_middleware(...) is LIFO. The last middleware added is the first one executed on an incoming request (and the last to see the response on the way out). Keep this in mind — the order you see in create_app reads "inside out".
Installation order (inside create_app)
# Added last → executed first (outermost wrapper)
app.add_middleware(CorrelationIdMiddleware)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(SessionMiddleware, secret_key=...)
for module in discovered_modules:
module.register_middleware(app) # each module may add 0+ middleware
if settings.multi_tenant:
app.add_middleware(TenantMiddleware)
app.add_middleware(LocaleMiddleware)
app.add_middleware(InertiaLayoutDataMiddleware)
# Added first → executed last (closest to the app)Execution order (per request)
CorrelationId
↓
RequestLogging
↓
SecurityHeaders
↓
Session
↓
<module middleware> ← in whatever order each module installed them
↓
Tenant (only if SM_MULTI_TENANT=true)
↓
Locale
↓
InertiaLayoutData
↓
app (route handler)The response flows back up in the reverse of this order.
What each built-in does
CorrelationIdMiddleware
Reads the X-Request-ID header (or generates a UUID4), puts it on request.state.correlation_id, and adds it to every log line via contextvars. Included in the response as X-Request-ID so clients can cross-reference logs.
RequestLoggingMiddleware
Emits a structured log line per request with method, path, status, duration, and correlation ID. Respects SM_LOG_FORMAT (plain vs JSON) and SM_LOG_LEVEL.
SecurityHeadersMiddleware
Sets conservative defaults: X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY, plus CSP / HSTS in production. Override on a per-route basis with your own response headers.
SessionMiddleware
Starlette's built-in signed-cookie sessions. Cookie name is session; attributes are HttpOnly, SameSite=Lax. SameSite=Lax is the CSRF defence: browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form submission from another origin is unauthenticated.
TenantMiddleware (opt-in)
Reads SM_TENANT_HEADER (default X-Tenant-ID) and sets request.state.tenant_id. The MultiTenantMixin auto-filters SELECTs and auto-populates INSERTs using this value.
LocaleMiddleware
Resolves the active locale in this order per request:
- Cookie named by
SM_I18N_COOKIE_NAME(validated against supported locales). Accept-Languageheader with q-value parsing and longest-prefix match (es-MX→es).SM_I18N_DEFAULT_LOCALE.
The resolved locale lands on request.state.locale and is used by InertiaLayoutDataMiddleware to pick the translation bundle for the shared props.
InertiaLayoutDataMiddleware
Runs last (closest to the app). Populates request.state.inertia_shared with:
auth.user,auth.isAuthenticated,auth.permissionsmenus— grouped byMenuSection, filtered by the current user's permissionsi18n—{ locale, bundle }for the resolved locale
Inertia responses pick these up automatically via InertiaDep from simple_module_hosting.inertia_deps.
Module middleware ordering
When two modules at the same dependency tier both call app.add_middleware(...) in their register_middleware hook, the framework invokes their hooks in topological order with a stable tiebreaker (module name). Because add_middleware is LIFO, the module that sorts later wraps its middleware outermost — so it runs first on the request.
Concretely, if modules alpha and beta both register middleware:
alpharuns first (alphabetical tiebreaker, nodepends_on).beta.register_middlewareruns last, so its middleware is the outermost wrap.- On a request:
beta.mw → alpha.mw → tenant → locale → app.
If you need a specific relative order, express it with ModuleMeta.depends_on. Do not rely on names — another module could be installed tomorrow that sorts differently.
Writing a module middleware
Use the Starlette pattern. Keep it asynchronous.
# modules/orders/orders/middleware.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
class OrdersRateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp, rate: int = 10) -> None:
super().__init__(app)
self.rate = rate
async def dispatch(self, request, call_next):
# quick: consult request.app.state.sm.db / Redis for counters
response = await call_next(request)
response.headers["X-Orders-Rate-Limit"] = str(self.rate)
return responseRegister:
# modules/orders/orders/module.py
def register_middleware(self, app: FastAPI) -> None:
app.add_middleware(OrdersRateLimitMiddleware, rate=10)Patterns to avoid
Reading request bodies. Middleware that calls await request.body() consumes the stream; downstream handlers see an empty body. Use ASGIApp directly and replace the receive channel if you truly need this, or move the logic into a dependency.
Mutating app.state per-request. app.state is shared across requests. Use request.state for request-scoped data.
Expensive setup per-request. Middleware __init__ runs once at installation; dispatch runs per request. Put constants in __init__.
Ordering via naming tricks. Prefixing modules with aa_ to make them sort first works — until another dev copies the pattern and two modules collide. Use depends_on instead.