Files
chatbot_v3/docs/adr/0012-application-resource-lifetime-and-dependency-ownership.md
Ali Zarinkolah 0ca698acfa docs(architecture): define application resource ownership
Why:
- Establish explicit ownership and lifetime rules for application, request/job, and operation-scoped resources.

Changes:
- Define FastAPI lifespan ownership for engines, clients, graphs, and pools.
- Define dependency-managed sessions and request context.
- Require explicit transaction boundaries and dependency passing.
- Prohibit shared global SQLAlchemy sessions and import-time network clients.

Impact:
- Application resources are created and closed by their process owner.
- Request and job resources must not be shared across concurrent units of work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:50:04 +03:30

9.7 KiB

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:

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:

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:

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:

# 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.

async with http_client.stream("GET", url) as response:
    ...

or:

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:

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.