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.

The create_app factory producing isolated application instances Inputs of settings, routers, middleware, and lifespan feed a central create_app function, which produces three isolated application instances for test, staging, and production, each with its own configuration and dependency overrides. Inputs (assembled once) Settings (Pydantic) Routers (APIRouter) Middleware chain Lifespan (resources) create_app( settings) test app in-memory DB · mocks staging app admin routes on production app docs off · real pools
One factory, many isolated instances: the same construction logic produces an in-memory test app, a staging app with admin routes, and a hardened production app.

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:

  1. Read configuration — accept a settings object (or build a default), so the caller controls the environment.
  2. Construct the app and attach state — create the FastAPI instance, store immutable settings on app.state, and register the lifespan.
  3. 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.state per request causes cross-request contamination. Keep app.state for 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_overrides produce 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.