Designing Thin FastAPI Routes

HTTP routes handle requests and responses while screen composition moves into application use cases

The Hangangjari backend is based on FastAPI. But the FastAPI process does not do everything.

The API returns screen-ready responses that the app and widgets can read immediately. External data collection, forecast calculation, push candidate selection, and delivery are handled by workers.

The reason for this separation is simple. When users open the app, they need a fast response. Public-data collection, however, can be slow, can fail, and can update on different schedules by source.

The tempting way to build an API is to read and compose everything inside route functions. In a small service, that feels faster. Hangangjari also seemed fine that way early on. But as park first screens, widgets, forecasts, and notification settings were added, routes began needing to know external sources, Redis, DB schema, and DTO shapes at the same time. Then even small changes spread in several directions.

So FastAPI was left with HTTP handling, and the user’s questions moved into application queries and use cases. In effect, “build this park’s first screen” was separated from “select these rows from this table.”

Routes only assemble HTTP boundaries

The backend is split into something close to clean architecture.

  • domain: entities and policies.
  • application: queries, commands, use cases, and ports.
  • infrastructure: SQLAlchemy, Redis, APNs, and external source adapters.
  • interfaces: HTTP routes and job entrypoints.
flowchart TB
  Route["FastAPI route"] --> Dependency["Dependency layer"]
  Dependency --> UseCase["Application query / command"]
  UseCase --> Port["Repository / cache port"]
  Port --> Infra["SQLAlchemy / Redis / APNs / source client"]
  Infra --> Store["Postgres / Redis / external service"]
  UseCase --> DTO["Pydantic DTO serialization"]
  DTO --> Client["iOS app / widget"]

FastAPI routes stay thin. Once a route starts knowing SQL queries or source adapters directly, screen needs and infrastructure details become mixed.

This does not mean the project is an ideal clean architecture example. In a personal project, splitting files and roles too finely can make reading harder. In Hangangjari, I mostly tried to keep these boundaries.

Routes handle HTTP parameters and response shape. Queries and use cases handle what the screen asks. Repository and cache adapters handle storage details. Keeping even this much separation made testing and incident analysis much easier.

API groups called by the app

The v2 app API area read by the app includes:

  • Parks/Parking: parks, parking lots, parking overview, and lot status.
  • Home Summary: screen-ready first-screen response that bundles parking, outing, forecasts, and refresh state.
  • Outing: per-park general overview and signal detail.
  • Forecast: parking forecast, park congestion forecast, and horizon timeline.
  • Telemetry: client performance events and product event batches.
  • Push: APNs subscription registration and removal.
  • App access bootstrap: app access validation and request proof issuance.

Admin routes are separated from user-facing APIs. Admin details do not leak into screen-ready responses.

Data read for a parking screen

The parking overview uses Redis and Postgres together. Master data is read from Postgres, and latest status checks Redis first.

sequenceDiagram
  autonumber
  participant Client as iOS app/widget
  participant Route as FastAPI route
  participant Query as Parking query
  participant Cache as Status cache
  participant Repo as Parking repository
  participant DB as Postgres

  Client->>Route: Request parking screen
  Route->>Query: Build parking screen values
  Query->>Repo: Read park and parking-lot reference data
  Repo->>DB: Query reference data
  Query->>Cache: Check latest parking status
  alt Cache miss or malformed entry
    Query->>Repo: Check last stored status
    Repo->>DB: Query status history
  end
  Query-->>Route: Return screen-ready read model
  Route-->>Client: Return response with freshness

The first screen is handled by home-summary, an endpoint that bundles several data types into one payload.

sequenceDiagram
  autonumber
  participant Client as App/widget
  participant Route as home-summary route
  participant Cache as Hot/stale cache
  participant Parking as Parking query
  participant Outing as Outing query
  participant Forecast as Forecast query
  participant DB as Postgres

  Client->>Route: Request first screen
  Route->>Cache: Check ready first-screen cache
  alt Hot cache hit
    Cache-->>Route: Cached first-screen response
  else Cache miss
    Route->>Parking: Build parking summary
    Route->>Outing: Gather outing signals and source status
    Route->>Forecast: Check forecast summary
    Parking->>DB: Read parking reference data
    Outing->>DB: Read outing reference data
    Forecast->>Cache: Read forecast response
    Route->>Cache: Store hot response and last-success value
  end
  Route-->>Client: Return first-screen response

This API simplifies the app first screen and general park widget. The client does not need to compose several APIs in sequence.

The benefit of home-summary was not only performance. It made the app and widget see the same values.

If the client combines several APIs, parking may be fresh while event information is old, or the widget may receive only part of the values and change the meaning of the screen. The server response aligns those differences at once.

Responses carry values and refresh state together

iOS apps are sensitive to API shape. Swift decoding feels server response changes immediately. So the backend does not expose internal DB schema directly; it uses DTOs.

DTOs handle these jobs.

  • Make optional fields and default policy explicit.
  • Keep datetime serialization consistent.
  • Distinguish fresh, stale, and unavailable.
  • Include source refresh state and generated/observed/fetched timestamps.
  • Hide raw payloads and internal policy values.
  • Make contracts harder to break while mixed app versions are in use.

Read requests also check app access

Current v2 app routes pass app access validation. The concrete platform validation implementation and request proof issuance are separated into the bootstrap side.

The choices ahead of platform-specific details were:

  • Read APIs can also be abused.
  • Write APIs validate request proof more strictly.
  • Authentication failure metrics are kept, but user-identifying information is not written directly to logs.
  • Operational metric endpoints stay invisible to normal user traffic.

What changed after keeping routes thin

Keeping FastAPI routes thin separated screen needs from storage details. App APIs were designed around user-facing screen units, not DB schema, and work that belongs to workers was not pulled into user requests.

In a public-data app, one value is not enough. Source, time, and refresh state have to travel with it. As the first screen became more complex, letting the server bundle screen-ready responses also helped client stability.

Once routes became thin, reasons for change became visible. Instead of routes knowing everything, use cases own the screen’s question. Then screen needs and storage details can be handled separately.

Share

Share

Image preview