Skip to content

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.

TableColumns (type notes)Relationship
customerid (UUID, PK), name (String 200), email (String 200, unique)orders
orderid (UUID, PK), customer_id (FK to customer), status (String 30, default pending), created_at (timezone-aware datetime)customer, items
order_itemid (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 ​

text
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 pattern

Startup: 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 on app.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 ​

Baseline request flowA client sends a request to a FastAPI route. The route receives a session from the shared sessionmaker through a dependency, which checks a connection out of the pool to Postgres or SQLite.ClientFastAPI routeasync defget_sessionshared sessionmakerPool and DBPostgres or SQLitethe gateway client is also shared, from app.state.http_client

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.

PatternBad pathGood pathWhere the difference lives
AP1requests.get() inside async defshared httpx.AsyncClient, awaitedapp/routers/demo_ap1.py
AP2new engine and new client per requestget_session and get_http_client singletonsapp/routers/demo_ap2.py
AP3plain order.items, or awaitable_attrs in a loopselectinload(Order.items) in the queryapp/routers/demo_ap3.py
AP4dict, nested validators, model_dump then model_validatefrom_attributes=True onceapp/schemas_ap4.py, app/routers/demo_ap4.py
AP5pool fixed at POOL_MODE=badpool fixed at POOL_MODE=goodapp/config.py, restart between runs

Request flow for AP1 ​

The blocking call stops the event loop. Both requests below are on one loop thread.

AP1 timeline, bad and goodBad path: three requests run one after another, about 1 second each. Good path: the three requests overlap and finish together after about 1 second.bad: requests.get() blocks the looprequest 1good: awaited, so the loop is freerequest 1, request 2 and request 3 overlap in the same window0 sabout 3 s

This timeline is schematic. The measured values are on the AP1 page.

Released under the MIT License. Speaker: Satyam Soni, PyCon Hong Kong 2026.