Unit 07.01: Settings and clients that are built once
Expensive objects should be built once and injected, not created per request.
A cached dependency is a singleton
Settings behind a cache, used by an endpoint called three times.
The code counts how many times it was built.
from functools import lru_cache
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic_settings import BaseSettings
BUILDS = []
class Settings(BaseSettings):
model_name: str = "fake-model-for-tests"
@lru_cache
def get_settings() -> Settings:
BUILDS.append(1)
return Settings()
app = FastAPI()
@app.get("/config")
def config(settings: Settings = Depends(get_settings)) -> dict:
return {"model": settings.model_name}
client = TestClient(app)
for _ in range(3):
client.get("/config")
print(f"three requests, settings built {len(BUILDS)} time(s)")
print("\n`lru_cache` is what makes this a singleton. The same pattern suits any")
print("expensive client -- a database pool, an HTTP session, a loaded model.")
Three requests, one construction. The cache is what makes it a singleton, and the same pattern suits any expensive object: a connection pool, an HTTP session, a loaded model.
Injecting rather than importing is what makes it replaceable in a test - which is the next unit but one, and the reason the whole suite can run without a real model.
The mistake this prevents
The mistake is a module-level global created at import time. It cannot be overridden in a test, it is built even when unused, and importing the module becomes a side effect - sometimes a network call.
Takeaway
Build expensive objects once behind a cached dependency and inject them. Module-level globals cannot be replaced in a test.
