FastAPI Architecture: Best Practices for Building Scalable APIs

11 min read
18 Dec 2025
Vikas Choudhary
FastAPI Architecture: Best Practices for Building Scalable APIs

FastAPI architecture is the set of decisions about how a FastAPI project is split into routers, services, repositories and schemas, and how those pieces are allowed to talk to each other. It is not about which decorators you use. It is about which code is permitted to know about which other code.

That distinction decides whether a project is still pleasant to work in at 50 endpoints. FastAPI makes the first ten endpoints effortless, which is exactly why so many teams reach production with everything in one file and no clear place to put the next feature.

This guide covers the structure, patterns and scaling decisions that hold up in production, with the trade-offs stated rather than glossed over.

What is FastAPI architecture?

At a working level, a FastAPI architecture answers four questions:

  • Where does an HTTP request enter, and what is it allowed to do there?
  • Where does business logic live, and what does it know about HTTP?
  • Where does data access live, and what does it know about your database?
  • How do configuration, authentication and database sessions reach the code that needs them?

A project with good answers can move a feature between teams, swap a database, or test business rules without spinning up a web server. A project without them tends to grow a single module that everything imports, which is the point at which changes start breaking unrelated features.

FastAPI layered architecture diagram showing route, service and repository layers, what each owns and what it must never contain

What actually makes FastAPI fast?

Three components do the work, and each one shapes an architectural choice.

Starlette and the ASGI event loop

FastAPI is built on Starlette, a lightweight ASGI framework that handles routing, middleware, websockets and lifecycle events. ASGI is what allows a single worker to hold thousands of open connections while waiting on a database or an external API, rather than one thread per request.

The architectural consequence: concurrency is a property you can design for. Streaming responses, websockets and long-poll endpoints do not need a separate stack.

Pydantic validation at the boundary

Pydantic models validate and coerce data as it enters and leaves the application. The value is not the validation itself but where it happens. Once a request has been parsed into a model, every layer beneath can assume the data is well formed, which removes defensive checks from business logic.

The rule worth enforcing: request and response schemas are separate types from database models. They change for different reasons and on different schedules.

Type hints as architecture, not decoration

Type hints drive dependency resolution, validation and the generated OpenAPI schema. They are load-bearing rather than cosmetic. On a codebase past a few thousand lines, they are also what lets a new developer follow a request through four layers without reading every function body.

How should you structure a FastAPI project?

This is the question most teams get wrong first, and FastAPI's own Bigger Applications guide is the official starting point. The practical version follows.

A directory layout that survives growth

Group by feature, not by file type. A layout that scales looks like this:

  • app/api/v1/ routers, one module per resource
  • app/services/ business logic, no FastAPI imports
  • app/repositories/ database access, one per aggregate
  • app/schemas/ Pydantic request and response models
  • app/models/ ORM models, kept separate from schemas
  • app/core/ configuration, security, shared dependencies
  • tests/ mirroring the same structure

The reason to group by feature is churn. When a requirement changes, the edits land in one directory rather than scattered across six type-based folders. A folder named after a type tells you nothing about why a file changed.

Two rules keep this layout honest as the project grows. First, a module in app/services/ must never import from app/api/. If it needs something the route has, that something is an argument. The moment a service imports a router, the layers have merged and only the directory names still suggest otherwise.

Second, app/models/ and app/schemas/ stay separate even when the two look identical on day one. They diverge the first time you add a field the API should not expose, such as an internal risk score or a soft-delete flag, and merging them means that field ships to clients by default. Separate types make exposure a decision rather than an oversight.

Tests mirroring the source tree is not cosmetic either. It makes missing coverage visible: a service module with no counterpart under tests/services/ is obvious at a glance, where a flat test directory hides it.

Why APIRouter is the unit of modularity

Each resource gets its own APIRouter with its own prefix, tags and dependencies. Routers are then included into the application at a single point. This keeps route registration explicit and makes it obvious which endpoints share authentication or rate limiting, because those dependencies are declared once on the router rather than repeated per endpoint.

Configuration with Pydantic Settings

Configuration belongs in a typed settings object loaded once at startup, using Pydantic Settings. Reading environment variables at the point of use spreads configuration across the codebase and moves failures from startup to runtime. A typed settings object fails immediately and visibly when a required variable is missing, which is when you want to find out.

Keep the settings object free of logic. If it starts deriving values or choosing behaviour, that decision has left the code that owns it and become invisible to anyone reading the feature.

How do dependencies reach the code that needs them?

Database sessions, the current user, feature flags and external clients all have to arrive somewhere. FastAPI's dependency injection is the intended route, and using it consistently is what keeps the layers separate.

A dependency should return something the layer beneath can use without knowing where it came from. A get_current_user dependency returns a user object, not a request. A get_session dependency yields a session and closes it afterwards, so no service has to remember cleanup. Once that holds, a service can be called from a test, a CLI command or a queue worker by passing the same arguments the dependency would have supplied.

Declare shared dependencies on the router rather than repeating them per endpoint. Authentication applied at the router is visible in one place and cannot be forgotten on a new endpoint, which is the usual way an unprotected route reaches production.

Build Ultra-fast, Scalable APIs With the Right FastAPI Architecture

What is the route, service, repository pattern?

It is a rule about direction. Routes call services, services call repositories, and nothing calls back upwards.

Layer

Responsibility

Must not contain

Route

Parse the request, check permissions, return a response

Business rules or database queries

Service

Business logic, orchestration, transactions

HTTPException, Request, or any FastAPI import

Repository

Database queries for one aggregate

Business decisions or HTTP concepts

What belongs in each layer

The test for a service is whether it can run in a scheduled job with no HTTP request present. If it raises HTTPException, it cannot, and the logic is trapped in the web layer. Services should raise domain errors that the route translates into status codes.

The test for a repository is whether swapping the database would touch any file outside that directory. If a raw query appears in a service, the answer is no.

Where teams get this wrong

Two failure modes are common. The first is a service layer that only forwards calls to the repository, adding a file and no value. If a service has no logic, the route can call the repository directly until it does.

The second is passing the ORM session down through every call so that transactions are managed in the route. Transactions belong in the service, because the service is what knows which operations must succeed together. A route that opens a transaction has taken on a decision it cannot make correctly, since it cannot see which of the three repository calls beneath it have to commit as one unit.

A third, quieter failure is the repository that returns ORM objects with lazy relationships still attached. The service then triggers database queries by reading an attribute, and query count becomes something nobody can predict from reading the code. Repositories should return fully loaded objects or plain data, decided at the query.

How does this make the code testable?

Testability is the practical payoff, and it is the fastest way to tell whether the boundaries are real. Business rules live in services that import nothing from FastAPI, so they can be tested by calling a function with arguments. No test client, no HTTP, no application startup.

Repositories are tested against a real database, because that is the thing they exist to talk to, and mocking a query proves nothing about whether the query is correct. Routes get a small number of tests through the test client covering status codes, permissions and serialisation, since that is all they should contain.

The signal to watch is the ratio. If most tests need the test client, logic has drifted upwards into the route layer, whatever the directory names say.

Error handling that does not leak

Services should raise domain errors such as InsufficientBalance or OrderAlreadyShipped. A single exception handler registered on the application maps those to status codes and response bodies.

The benefit is that the mapping is in one place, so an error's HTTP representation can change without touching business logic, and the same service raises the same error whether it was called by a route or a scheduled job. Raising HTTPException inside a service is the shortcut that removes both of those properties.

How should you version a FastAPI API?

Version in the URL path, from the first release, even when there is only one version. Adding /v1/ later is a breaking change for every client, while starting with it costs nothing.

A version is a contract, not a folder. The discipline that makes it work is that v1 routers keep calling v1 schemas after v2 exists. Sharing schemas across versions is what turns a versioned API into an unversioned one with extra directories.

Deprecate on a published timetable: mark the endpoint deprecated in OpenAPI, add a sunset header, and give clients a date. Silent removal is how integrations break.

Not every change needs a new version. Adding an optional field or a new endpoint is backward compatible and belongs in the current version. Removing a field, renaming one, tightening validation or changing a default are all breaking, even when they look small, because a client somewhere is depending on the old behaviour. The test is whether an existing integration written last year still works untouched.

Two versions in production is manageable. Three is a maintenance problem, and the way to avoid it is to agree the sunset date for v1 at the same time v2 ships, rather than when someone notices v1 is still running.

When should an endpoint actually be async?

Async helps when an endpoint waits on something external: a database, an HTTP call, a queue. It does nothing for CPU-bound work, and as FastAPI's async documentation sets out, a blocking call inside an async endpoint stalls the entire event loop, not just that request.

The rule that avoids the common failure: if any library in the call path is synchronous, define the endpoint with def rather than async def. FastAPI will run it in a threadpool, which is slower per request but does not block everything else. Mixing a synchronous database driver into an async endpoint is the most common way a fast framework becomes a slow one.

Background tasks, and when to reach for a queue

BackgroundTasks is appropriate for short work that can be lost without consequence, such as a log write or a cache warm. It runs in the same process, so a restart discards it.

Anything that must complete belongs in a real queue with retries and a dead letter path. Sending an invoice, charging a card or generating a report are queue work, not background tasks. The distinction is durability, not duration.

Caching as an architectural decision, not a patch

Caching added after a performance complaint tends to land in whichever function was slow, which spreads invalidation logic everywhere. Decide up front which layer caches: usually the repository for read-heavy queries, and the route for full responses that are identical across users.

Write the invalidation rule at the same time as the cache. A cache without a documented invalidation path becomes a source of stale-data bugs that are difficult to reproduce.

How do you scale FastAPI in production?

Workers, containers and autoscaling

A single async worker uses one CPU core. Production runs multiple worker processes behind a process manager, then multiple containers behind a load balancer. Set worker counts from measured CPU use rather than a formula, because an async workload that is mostly waiting behaves differently from a synchronous one.

For autoscaling, request latency and queue depth are better signals than CPU. An async service can sit at low CPU while every connection waits on a saturated database, and CPU-based autoscaling will not notice.

Queues and event-driven work

The database is usually the first thing to break under load, not the application. Connection pools are finite, and each worker holds its own. Count total connections across all workers and containers before scaling out, or the database refuses connections at exactly the moment traffic peaks.

Moving slow work to a queue helps twice: request latency drops, and the queue absorbs bursts that would otherwise arrive at the database all at once.

What should you monitor in a FastAPI service?

Three things, in this order of usefulness when something is wrong:

  • Structured logs with a request id that follows the request through every layer, so one incident can be reconstructed
  • Latency percentiles per endpoint, not averages. An average hides the slow tail that users actually complain about
  • Distributed tracing once more than one service is involved, so time can be attributed to the component responsible

Add health endpoints that check dependencies rather than returning a constant. A health check that always returns 200 tells the orchestrator to keep sending traffic to a container that cannot reach its database.

When should you split into microservices?

Later than most teams do. FastAPI makes services easy to create, which is not the same as making them easy to operate. A well-structured monolith with clean service boundaries can be split later, and the split is easier because the boundaries already exist.

Reasonable triggers are independent scaling needs, genuinely separate release cadences, or a team boundary that already exists in the organisation. Poor triggers are file count, framework fashion, or a belief that smaller services are automatically simpler. They move complexity from the codebase into the network, where it is harder to see and harder to debug.

This is also where clean architecture and domain-driven design earn their keep, and where they are most often misapplied. The useful part is the dependency rule: business logic does not import infrastructure. The route-service-repository split already delivers most of that. Adding entities, value objects, aggregates and a full ports-and-adapters layer to a service with six endpoints buys ceremony rather than clarity.

A reasonable sequence is to adopt the dependency rule first, keep the monolith, and let bounded contexts emerge from where changes actually cluster. When two areas of the codebase stop sharing changes for several months, they are a real boundary and can be separated with evidence rather than prediction.

Ready to Take Your Backend From Good to Exceptional?

Summary

FastAPI architecture is mostly about direction and boundaries. Routes handle HTTP, services hold business logic and know nothing about HTTP, repositories own data access. Group directories by feature so related changes stay together. Version from the first release. Use async where the work waits on something external, and stay synchronous where the libraries are.

The decisions that hurt most later are the cheapest to make early: where business logic lives, whether schemas are separate from database models, and whether configuration is typed and loaded once. None of them slow down the first release, and all of them decide how the fiftieth endpoint feels.

Zyneto builds and scales production FastAPI systems, from the initial structure through to autoscaling and observability. If you are planning a new API or untangling one that outgrew its structure, our FastAPI development team can help you scope it.

FAQs

Group by feature rather than by file type: routers under app/api/v1/, business logic in app/services/, database access in app/repositories/, and Pydantic schemas kept separate from ORM models. Feature grouping means a requirement change edits one directory instead of six.

A rule about direction. Routes parse requests and return responses, services hold business logic and import nothing from FastAPI, and repositories own database queries. Calls only go downwards. The test for a service is whether it can run in a scheduled job with no HTTP request present.

No. Async helps when an endpoint waits on something external such as a database or an HTTP call, and does nothing for CPU-bound work. If any library in the call path is synchronous, use def rather than async def so FastAPI runs it in a threadpool. A blocking call inside an async endpoint stalls the whole event loop.

Version in the URL path from the first release, even with only one version, because adding /v1/ later breaks every client. Keep v1 routers calling v1 schemas after v2 exists; sharing schemas across versions is what quietly turns a versioned API back into an unversioned one.

BackgroundTasks suits short work that can be lost without consequence, since it runs in the same process and a restart discards it. Anything that must complete, such as sending an invoice or charging a card, belongs in a queue with retries and a dead letter path. The distinction is durability, not duration.

Run multiple worker processes behind a process manager and multiple containers behind a load balancer. Autoscale on request latency or queue depth rather than CPU, because an async service can sit at low CPU while every connection waits on a saturated database. Count total database connections across all workers before scaling out.

Usually not, and later than most teams think. A well-structured monolith with clean service boundaries splits easily when it needs to, because the boundaries already exist. Split for independent scaling, separate release cadences or an existing team boundary, not for file count.

Vikas Choudhary

Vikas Choudhary

Vikas Choudhary has spent more than ten years writing software and now builds generative AI systems at Zyneto. His work covers retrieval augmented generation, agentic AI, knowledge graphs, AI memory, and the evaluation and guardrails that decide whether any of it is safe to put in front of customers. He has shipped enterprise copilots, document AI, chatbots and predictive analytics for e-commerce, fintech and marketing teams, and works day to day in Python, JavaScript and SQL. He follows multimodal models, business process automation and enterprise AI security closely, and mentors engineers moving into AI. He writes about architecture, inference cost and the failure modes that only show up at production scale.

Let's make the next big thing together!

Share your details and we will talk soon.

Phone

We respond to all inquiries within 1 hour.

WhatsApp
Email
Book a Meeting