Application Factory Patterns in FastAPI
An application factory is a create_app() function that constructs and returns a fully wired FastAPI instance, replacing the module-level app = FastAPI() that couples your application to import time.
This pattern is the construction half of the architecture described in Core Architecture and Routing Patterns: the factory is where configuration, routers, middleware, and the lifespan are assembled into one object. It pairs directly with Configuration Management, which supplies the typed settings the factory consumes, and with Modular Router Organization, which supplies the routers it mounts. If you only adopt one structural pattern from this section, make it this one — almost every other pattern composes through the factory.
Why a Module-Level Instance Breaks Down
A static app = FastAPI() at module scope is shared across the entire process. The moment a second consumer appears — a test suite, a second worker, a CLI command that imports your routers — they all inherit the same object and any state attached to it. In async deployments this surfaces as connection pools and HTTP clients that persist across test boundaries, middleware that has to be toggled with brittle if ENV == "test" guards, and duplicate route registration warnings on hot reload.
The factory replaces shared construction with explicit construction. Each create_app() call returns an independent object graph, so a test can build an app with an in-memory database while production builds one with a real connection pool, and the two never interfere.
Core Mechanics: What the Factory Owns
The factory has exactly three responsibilities, and keeping them separate is what makes the pattern robust:
- Read configuration — accept a settings object (or build a default), so the caller controls the environment.
- Construct the app and attach state — create the
FastAPIinstance, store immutable settings onapp.state, and register the lifespan. - Mount routers and middleware synchronously — so the OpenAPI schema is complete before the server accepts traffic.
Resource acquisition deliberately does not live in the factory. It lives in the lifespan context manager, which runs after construction. This separation keeps create_app synchronous and free of network calls, which in turn keeps it cheap to call thousands of times in a test suite.
# app/main.py
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
from fastapi import FastAPI
from app.config import Settings, get_settings
from app.db import create_async_pool
from app.routers import api_router, admin_router, health_router
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Resources are acquired here, after construction, not inside create_app().
settings: Settings = app.state.settings
app.state.db_pool = await create_async_pool(settings.database_url)
yield
await app.state.db_pool.close() # Drain in-flight requests, then release.
def create_app(settings: Settings | None = None) -> FastAPI:
cfg = settings or get_settings()
app = FastAPI(
title=cfg.project_name,
lifespan=lifespan,
# Disable interactive docs outside development to shrink attack surface.
docs_url="/docs" if cfg.environment != "production" else None,
)
app.state.settings = cfg # Immutable handle the lifespan and deps can read.
app.include_router(health_router, prefix="/health", tags=["observability"])
app.include_router(api_router, prefix="/v1", tags=["public"])
if cfg.environment in ("development", "staging"):
app.include_router(admin_router, prefix="/admin", tags=["internal"])
return app
Production Implementation: Serving the Factory
Both Uvicorn and Gunicorn understand factories natively. The --factory flag tells the server that the import target is a callable to invoke once per worker, rather than an already-built app object.
# Each worker process calls create_app() exactly once — no import-time side effects.
CMD ["uvicorn", "app.main:create_app", "--factory", \
"--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
Because every worker constructs its own instance, there is no shared mutable state to corrupt under concurrency. The only cross-worker resources are the external ones — the database, the cache — which are reached through their own pools, each acquired in that worker's lifespan.
Async Correctness and Serverless Notes
On serverless platforms such as AWS Lambda or Cloud Run, eager resource acquisition at startup becomes a cold-start liability. The factory accommodates this without changing shape: keep create_app doing pure construction, and make the lifespan acquisition lazy or pool through an external proxy (PgBouncer, RDS Proxy) so a freshly spun container does not pay full pool-creation latency on its first request. The deeper async implications of where blocking work runs are covered under Async Correctness and Concurrency.
The one rule that prevents the most common production incident: never do blocking I/O directly in the lifespan startup path without offloading it. A synchronous migration or a slow network call there blocks the ASGI server, delays the first health check, and can trigger an orchestrator restart loop.
Testing Strategy
The factory exists largely to serve testing. A fixture builds a fresh app per test, overrides external dependencies, and tears down cleanly. The detailed walk-through lives in FastAPI App Factory Pattern for Testing and Deployment, but the core shape is short:
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
from app.config import Settings
from app.deps import get_db_session
@pytest.fixture
def client():
app = create_app(Settings(environment="test", database_url="sqlite+aiosqlite:///:memory:"))
app.dependency_overrides[get_db_session] = fake_session_factory
with TestClient(app) as c: # Context manager runs lifespan startup/shutdown.
yield c
app.dependency_overrides.clear() # Prevent override bleed into the next test.
The two non-negotiables are constructing TestClient inside a with block, so lifespan startup and shutdown actually run, and clearing dependency_overrides afterward so mocks never leak into the next test.
Failure Modes and Debugging
- Global state leakage between requests. Attaching mutable per-request objects to module scope or mutating
app.stateper request causes cross-request contamination. Keepapp.statefor immutable handles only and route per-request state through dependencies. - Routers mounted inside lifespan. Including routers after startup means they miss the generated OpenAPI schema. Always mount synchronously in
create_app. - Forgotten override cleanup. Leftover entries in
dependency_overridesproduce tests that pass alone but fail in a suite. Clear them in fixture teardown. - Blocking calls in startup. Wrap unavoidable synchronous work with
asyncio.to_thread()and gate readiness behind a health probe.
Related Reading
- Up to the section: Core Architecture and Routing Patterns.
- Hands-on guide: FastAPI App Factory Pattern for Testing and Deployment.
- Composes with: Configuration Management, Modular Router Organization, and Dependency Injection Strategies.