# 0012. Application resource lifetime and dependency ownership ## Status Proposed ## Context ADR-0008 defines the FastAPI REST boundary and says the app lifespan owns long-lived resources. ADR-0009 chooses SQLAlchemy 2.x, Alembic migrations, and async sessions for the Postgres layer. We need a project-wide rule for how those resources, and future resources with similar lifetimes, are created, shared, and closed. The service will need objects with different lifetimes: - application-lifetime objects such as SQLAlchemy engines, session factories, Qdrant clients, LangGraph checkpointers/stores, compiled graphs, HTTP clients, observability clients, model clients, Redis clients, or other connection pools; - request/job-lifetime objects such as SQLAlchemy sessions, transactions, authentication context, tenant context, request ids, and unit-of-work state; - operation-lifetime objects such as temporary files, one-off streams, locks, or short-lived connections checked out for a single block. Using module-level mutable resource instances for everything would make startup, shutdown, testing, concurrency, and transaction boundaries hard to reason about. The most dangerous example is a global SQLAlchemy `Session`: sessions track identity state and transaction state, and are not safe to share across concurrent requests. At the same time, creating expensive clients or connection pools for every request would waste resources and lose pooling benefits. We need explicit ownership: the component that creates a resource is responsible for closing it. ## Decision ### Use FastAPI lifespan for application-lifetime resources Create long-lived shared resources in the FastAPI lifespan function and close them after the lifespan `yield`. Application-lifetime resources include: - SQLAlchemy async `Engine`; - SQLAlchemy `async_sessionmaker` bound to that engine; - Qdrant client; - LangGraph checkpointer/store and compiled graph; - shared `httpx.AsyncClient` instances; - Redis or cache clients; - Langfuse/observability clients; - embedding, reranking, LLM, or other expensive model clients. The lifespan function is the owner of these resources. It is responsible for calling cleanup methods such as `dispose()`, `aclose()`, `close()`, or equivalent shutdown hooks. Indicative shape: ```python from collections.abc import AsyncIterator from contextlib import asynccontextmanager import httpx from fastapi import FastAPI @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.database = Database(settings.database_url) app.state.http_client = httpx.AsyncClient() try: yield finally: await app.state.http_client.aclose() await app.state.database.close() app = FastAPI(lifespan=lifespan) ``` Avoid opening external resource clients as import-time side effects. Module-level constants, settings objects, pure functions, type aliases, and stateless helpers are acceptable. Mutable network/database clients should be constructed by the application owner, not by importing a module. ### Treat SQLAlchemy engine and session as different lifetimes A SQLAlchemy `Engine` is an application-lifetime pool manager, not a single request's transaction. It may keep database connections open in a pool and reuse them across requests. A SQLAlchemy `AsyncSession` is a request/job-lifetime unit of work. It tracks ORM identity state and transaction state and may check out a database connection from the engine when needed. Therefore: - create one engine/session factory per FastAPI process during lifespan; - create one `AsyncSession` per request or background-job unit of work; - never share one `AsyncSession` globally across concurrent requests; - never run DDL such as `create_all()` at FastAPI startup; use Alembic as decided in ADR-0009. ### Use dependencies for request-lifetime resources Use FastAPI dependencies, usually `yield` dependencies, for objects that should exist for one request and then be cleaned up. Indicative database session dependency: ```python from collections.abc import AsyncIterator from fastapi import Request from sqlalchemy.ext.asyncio import AsyncSession async def get_db_session(request: Request) -> AsyncIterator[AsyncSession]: database: Database = request.app.state.database async with database.session_factory() as session: try: yield session except Exception: await session.rollback() raise ``` The dependency owns the session lifetime. Route handlers, services, and repositories must not close a session they did not create. Authentication and tenant context follow the same rule: resolve them through request dependencies, then pass the trusted context into code that needs it. ### Keep transaction boundaries explicit Routes or application-service functions own transaction boundaries. Repository and CRUD functions receive a session and perform database work, but they should not secretly commit, rollback, or close the session unless their contract explicitly says they own a complete unit of work. Preferred shape: ```python async def create_user(session: AsyncSession, data: CreateUserRequest) -> User: user = User(email=data.email) session.add(user) return user @router.post("/users") async def create_user_endpoint( body: CreateUserRequest, session: SessionDep, ) -> UserResponse: user = await create_user(session, body) await session.commit() return UserResponse.model_validate(user) ``` Avoid hidden global access: ```python # Do not do this. session = SessionLocal() async def create_user(data: CreateUserRequest) -> User: session.add(User(email=data.email)) await session.commit() ``` This keeps unit-of-work ownership visible, makes multi-step transactions easier, and lets tests pass in their own sessions. ### Use context managers for operation-lifetime resources Objects needed only inside one function or block should use `with` or `async with` rather than app state or module globals. Examples include file handles, temporary streams, one-off SQLAlchemy connections, locks, and response streams. ```python async with http_client.stream("GET", url) as response: ... ``` or: ```python async with engine.connect() as connection: ... ``` The context manager owns and releases the resource at block exit. ### Pass dependencies explicitly through service boundaries Routers adapt HTTP requests into typed application calls. Services and repositories should receive the resources they use as explicit parameters: ```python async def get_point( session: AsyncSession, qdrant: QdrantClient, auth: AuthContext, point_id: str, ) -> PointResponse: ... ``` Do not have lower layers import mutable resource singletons. Explicit parameters make ownership clear and allow FastAPI dependency overrides, test fixtures, and background workers to supply equivalent resources. ### Account for worker process multiplicity FastAPI lifespan runs once per worker process. If the service runs with multiple Uvicorn/Gunicorn workers, each worker has its own app instance, engine, and connection pool. Database pool sizes and external client limits must be chosen with worker count in mind. ## Consequences ### Positive - Resource ownership and cleanup become explicit: lifespan closes app resources, dependencies close request resources, and context managers close block-scoped resources. - SQLAlchemy sessions are not shared across concurrent requests, avoiding unsafe identity-map and transaction-state reuse. - Connection pooling is preserved because expensive clients and engines are created once per process instead of once per request. - Transaction boundaries are easier to reason about because routes or application services decide when to commit or rollback. - Services and repositories are easier to test because sessions, clients, and auth contexts can be injected directly. - The same policy can be reused for future Redis, Qdrant, Langfuse, HTTP, model, and worker resources. ### Negative - This adds boilerplate: lifespan setup, app state typing/conventions, dependencies, and explicit parameters must be maintained. - Developers must distinguish engine/session, application/request, and owner/borrower lifetimes instead of importing a convenient global. - App state access needs discipline and typing helpers so resources do not become an unstructured service locator. - Multi-worker deployments require explicit pool sizing because each process owns its own pools and clients. ## Alternatives Considered - **Module-level resource singletons**: rejected as the default for mutable external resources. Import-time construction makes startup order, tests, reloads, and shutdown cleanup harder to control. Module-level constants and stateless helpers remain acceptable. - **One global SQLAlchemy session**: rejected. A session is a unit of work with identity and transaction state, not an application-wide pool. Sharing it across requests is unsafe and makes rollback/error handling ambiguous. - **Create every client/session inside each CRUD function**: rejected. It hides ownership and transaction boundaries, prevents multi-step units of work, and wastes pooling benefits for expensive clients. - **Automatically commit every successful request in the session dependency**: deferred as a default. It reduces route boilerplate, but it can make write boundaries too implicit and does not fit every read/write or multi-transaction flow. Routes or application services should own commits unless a future ADR adopts a unit-of-work abstraction. - **Use a generic global service locator**: rejected for now. It can reduce parameter lists, but it obscures dependencies and ownership. FastAPI dependencies plus explicit function parameters are clearer at this stage.