Sub Applications (Mounts)
FastAPI supports mounting multiple independent applications under a single
parent process via app.mount(). Each sub-application has
its own routes and middleware stack, but all of them share the same Python
process — and therefore the same global resources (loguru logger, database
connection pools, cache backends).
Middleware Ownership
register_middleware() installs TraceIDMiddleware, ServiceNameMiddleware,
and AccessLogMiddleware on the target app. Do not register middleware on
both the parent and a sub-application for the same request path — a request
routed through a mount already passes through the sub-app's middleware stack.
Adding middleware on the parent as well causes duplicate request IDs and
double access-log entries.
Recommended: register middleware on each sub-application that owns endpoints.
from fastapi import FastAPI
from zodiac_core.logging import setup_loguru
from zodiac_core.middleware import register_middleware
setup_loguru(level="INFO", json_format=True, service_name="gateway")
parent_app = FastAPI()
orders_app = FastAPI()
users_app = FastAPI()
register_middleware(orders_app, service_name="orders")
register_middleware(users_app, service_name="users")
parent_app.mount("/orders", orders_app)
parent_app.mount("/users", users_app)
If the parent app has its own routes (e.g. /health), register middleware on
the parent only for those routes and be aware that requests to mounted
sub-apps will also pass through the parent's middleware — trace IDs and
access logs will appear twice unless you explicitly skip them (for example via
exclude_paths).
Sub-application Lifespan
app.mount() does not trigger sub-application lifespan events. If a
sub-app uses lifespan for resource setup (e.g. db.setup()), the parent
must enter its lifespan context explicitly:
from contextlib import AsyncExitStack, asynccontextmanager
from fastapi import FastAPI
orders_app = create_orders_app()
users_app = create_users_app()
@asynccontextmanager
async def lifespan(app: FastAPI):
async with AsyncExitStack() as stack:
await stack.enter_async_context(
orders_app.router.lifespan_context(orders_app)
)
await stack.enter_async_context(
users_app.router.lifespan_context(users_app)
)
yield
parent_app = FastAPI(lifespan=lifespan)
parent_app.mount("/orders", orders_app)
parent_app.mount("/users", users_app)
Without this, any lifespan logic on a sub-application is silently skipped.
Logging: One Setup, Per-App Identity
setup_loguru() configures the process-wide logger. Call it once in the
entry point. Per-app identity comes from register_middleware(app,
service_name=...) — the patcher resolves the service name at log time:
- Request-scoped: value from
ServiceNameMiddleware(ContextVar) - Global fallback:
service_namepassed tosetup_loguru()
Database: Shared or Named
db.setup() supports named engines. Use the default name for a shared
database, or distinct names when sub-apps need their own:
from zodiac_core.db.session import db
# Shared — all apps use the same database
db.setup("postgresql+asyncpg://shared_db_url")
# Per-app — separate databases
db.setup("postgresql+asyncpg://orders_db_url", name="orders")
db.setup("postgresql+asyncpg://users_db_url", name="users")
Repositories bind to a named engine via db_name:
Generated repositories already own their sessions through
BaseSQLRepository.session() and bind them with db_name, so their routes do
not need a session dependency. Only when a route deliberately owns a shared
unit of work or performs database work directly should it bind a named session
once in server-side router wiring:
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from zodiac_core.db import session_dependency
get_orders_session = session_dependency("orders")
@router.get("/")
async def list_orders(
session: Annotated[AsyncSession, Depends(get_orders_session)],
):
...
All partial(get_session, ...) forms are unsupported; keyword-bound partials
fail during route registration instead of silently selecting the wrong
database. Use session_dependency("orders") instead. Do not pass
session_dependency itself to Depends or derive a database name from request
data. Lower layers must never call a FastAPI dependency. If the route owns a
unit of work, it may pass the concrete AsyncSession to participating services
and repositories; otherwise the existing repository-owned self.session()
pattern remains correct. Jobs and other non-FastAPI unit-of-work owners should
use async with db.session("orders"). See
Choosing a Session API for the full
boundary.
Existing server-side wrapper dependencies that call get_session("orders")
remain source-compatible. Preserve them when compatibility requires it, but
migrate touched routes and generate new named route dependencies with
session_dependency("orders") so FastAPI directly manages rollback and
cleanup.
Idempotent
Calling db.setup() again with the same name and identical configuration
is harmless. Different configuration for an existing name raises
RuntimeError.
Cache: Shared or Named
cache.setup() requires a prefix and supports optional name for isolation:
from zodiac_core.cache.manager import cache
# Shared
cache.setup(prefix="shared")
# Per-app
cache.setup(prefix="orders", name="orders")
cache.setup(prefix="users", name="users")
Use cache.get_cache(name) to obtain a specific backend instance.
Shutdown Ownership
The component that calls setup is responsible for calling shutdown.
Warning
db.shutdown() and cache.shutdown() without a name close every
registered resource, not just the default. In a multi-app process,
always pass an explicit name to shut down only the intended resource:
If the parent app entered sub-app lifespans, those lifespans should call
db.shutdown(name="...") and cache.shutdown(name="...") for the resources
they created, rather than relying on a bare shutdown().
Summary
| Concern | API | Call site |
|---|---|---|
| Log sinks & format | setup_loguru() |
Once, entry point |
| Per-app log identity | register_middleware(app, service_name=...) |
Each sub-app |
| Middleware (trace / access log) | register_middleware(app) |
Each sub-app |
| Database (shared) | db.setup(url) |
Entry point or lifespan |
| Database (per-app) | db.setup(url, name="...") |
Sub-app lifespan or entry point |
| Route session (named DB) | get_named_session = session_dependency("...") |
Module or router wiring |
| Cache (shared) | cache.setup(prefix="...") |
Entry point or lifespan |
| Cache (per-app) | cache.setup(prefix="...", name="...") |
Sub-app lifespan or entry point |