Architecture
In one sentence
The baseline service is written correctly; each anti-pattern is a second endpoint that differs from the good one by only a line or two, which is the point.
Domain model
The domain is Customer → Order → OrderItem, defined with SQLAlchemy 2.0 async in app/models.py.
| Table | Columns (type notes) | Relationship |
|---|---|---|
customer | id (UUID, PK), name (String 200), email (String 200, unique) | orders |
order | id (UUID, PK), customer_id (FK to customer), status (String 30, default pending), created_at (timezone-aware datetime) | customer, items |
order_item | id (UUID, PK), order_id (FK to order), sku (String 50), quantity (int), unit_price (Numeric 10,2) | order |
The base class is Base(AsyncAttrs, DeclarativeBase). AsyncAttrs is what provides awaitable_attrs, used in AP3.
Lazy by default, on purpose
Order.items uses the default lazy="select". The source comments say this is deliberate, so the AP3 failure modes can be shown. The good path overrides it per query with selectinload. The model-level alternative, lazy="raise", is discussed on the AP3 page.
App layout
orders-api-demo/
app/
main.py App, lifespan, router registration, /health
config.py Settings and the bad/good pool profiles
db.py Engine and sessionmaker factories, get_session dependency
models.py Customer, Order, OrderItem
schemas.py Baseline Pydantic schemas
schemas_ap4.py AP4 bad and good transforms, and the TypedDict summary
clients.py get_http_client dependency (the lifespan singleton)
routers/
orders.py Baseline endpoints, written correctly
demo_ap1.py Blocking vs async vs executor bridge
demo_ap2.py Per-request vs shared engine and client
demo_ap3.py Crash, N+1, and selectinload
demo_ap4.py Redundant round-trip vs from_attributes
demo_ap5.py Slow query and pool info
mock_gateway/main.py Stand-in for the payment gateway
scripts/ Seeding, benchmarks, profilers, and locust
tests/ One pytest file per anti-pattern, plus baseline tests
benchmarks/ Captured output and an explanatory README per patternStartup: the lifespan
app/main.py builds the shared objects once, in the FastAPI lifespan:
- the async engine, with the pool settings from
POOL_MODE Base.metadata.create_all, which creates the tables on first start- the sessionmaker, stored on
app.state.sessionmaker - one
httpx.AsyncClient(timeout=10.0), stored onapp.state.http_client
On shutdown the client is closed and the engine disposed. Everything in this list is a singleton. AP2 is the anti-pattern where a route builds these objects itself instead.
Request flow: the baseline
The baseline routes in app/routers/orders.py use Annotated[AsyncSession, Depends(get_session)], and get_session hands out a session from request.app.state.sessionmaker. The detail endpoint eager-loads items with selectinload. See Baseline API.
How each pair is wired
Every pair runs on the same process. The table shows what differs between bad and good.
| Pattern | Bad path | Good path | Where the difference lives |
|---|---|---|---|
| AP1 | requests.get() inside async def | shared httpx.AsyncClient, awaited | app/routers/demo_ap1.py |
| AP2 | new engine and new client per request | get_session and get_http_client singletons | app/routers/demo_ap2.py |
| AP3 | plain order.items, or awaitable_attrs in a loop | selectinload(Order.items) in the query | app/routers/demo_ap3.py |
| AP4 | dict, nested validators, model_dump then model_validate | from_attributes=True once | app/schemas_ap4.py, app/routers/demo_ap4.py |
| AP5 | pool fixed at POOL_MODE=bad | pool fixed at POOL_MODE=good | app/config.py, restart between runs |
Request flow for AP1
The blocking call stops the event loop. Both requests below are on one loop thread.
This timeline is schematic. The measured values are on the AP1 page.