How to Structure a Scalable FastAPI Project
Learn the best practices for organizing FastAPI apps with a maintainable, scalable architecture.
🧱 How to Structure a Scalable FastAPI Project (The Right Way)
When FastAPI first hit the scene, it felt like a breath of fresh air: fast, intuitive, type-safe, and actually fun to use. You could whip up a working API in just a few lines:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "World"}
This minimal example shows how easy it is to define a basic route. You import FastAPI, create an app instance, and then decorate a function with a route — GET / here — returning a simple JSON response. For prototypes and demos, this flat structure works great.
But that’s only until your project grows beyond a few endpoints and starts needing features like authentication, database integration, or background tasks. Suddenly, your main.py becomes a messy catch-all for all the app’s logic, and maintenance gets painful.
🚨 The Problem with Flat FastAPI Apps
Many beginners and even intermediate devs start their apps with a flat layout like this:
main.py
models.py
routes.py
schemas.py
database.py
This might seem straightforward, but as your project scales and you add more domains — like users, payments, products — this approach quickly falls apart.
- Adding new features forces you to cram more code into these few files.
- Business logic, database access, and request validation get mixed up, reducing readability.
- Writing tests becomes cumbersome because logic isn’t separated.
- Onboarding new developers takes longer, since there’s no clear structure or boundaries.
✅ What You Want Instead
A better approach is a modular, domain-driven structure. This means grouping files and code by their domain or feature, rather than by technical type only.
Here’s an example directory structure inspired by a real FastAPI project:
backend/
├── app/
│ ├── config/ # Global settings & security
│ ├── db/ # Database connection & base models
│ ├── email/ # Email logic + templates
│ │ ├── templates/
│ │ │ ├── reset_password_email.html
│ │ │ └── verify_user_email.html
│ │ └── emails.py
│ ├── routers/ # API routes grouped by feature
│ │ ├── auth/
│ │ │ ├── auth.py
│ │ │ ├── models.py
│ │ │ ├── oauth_providers.py
│ │ │ ├── services.py
│ │ │ ├── tasks.py
│ │ │ ├── validators.py
│ │ │ └── tests/
│ ├── routers/core/ # Health checks, public info
│ ├── routers/payments/ # Payment logic
│ └── __init__.py
├── alembic/ # Database migrations
├── .env # Environment config
This structure clearly separates concerns:
config/holds your global settings, like environment variables and secrets.db/manages your database connection and base models in one place.email/contains everything related to email — templates and sending logic.routers/groups routes by feature, with subfolders for domains like auth and payments. Each domain can have its own models, services, validators, and tests.
This way, each domain owns its code and logic, making the app easier to maintain, test, and extend.
🔩 app/config/: Centralized Configuration
Managing configuration is crucial for any app. FastAPI plays nicely with Pydantic’s BaseSettings class, which lets you declare environment variables as Python attributes with type validation.
Example:
from pydantic import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "MyAwesomeApp"
DATABASE_URL: str
SECRET_KEY: str
class Config:
env_file = ".env"
settings = Settings()
Here, you create a Settings class that reads variables from a .env file. This way, you keep secrets and environment-specific settings (like your database URL or secret keys) outside your codebase, improving security and flexibility.
You can then import settings anywhere in your app without duplicating environment handling code. If you deploy to staging or production, you only need to swap .env files or environment variables.
You might also add a security.py file here for reusable helpers, like JWT token management or OAuth scopes, keeping all security-related configuration in one place.
🧱 app/db/: Database Logic, Models & Session
Your database connection, ORM models, and session management belong together in one module to avoid scattering DB setup across your code.
Here’s how you might configure an asynchronous SQLAlchemy engine and session factory:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.config.config import settings
engine = create_async_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with SessionLocal() as session:
yield session
create_async_enginecreates a connection to your database using the URL from your config.SessionLocalis a factory that creates new sessions, set up for async support.get_dbis a dependency generator you can inject into your endpoints. It handles opening and closing the session cleanly, so your routes can work with a database session without worrying about connection management.
Separately, keep your SQLAlchemy models organized either inside a models/ subfolder per domain or as single files if small. Also use a base.py file to hold your declarative base class that all models inherit from.
This separation helps isolate database logic, making it easier to maintain and test.
📨 app/email/: Clean Email Sending with Templates
Emails are usually a distinct part of your app, with their own logic and templates. Keeping them separate avoids cluttering core business logic with email details.
Consider this emails.py example using fastapi-mail and Jinja2:
from fastapi_mail import FastMail, MessageSchema
from jinja2 import Template
async def send_reset_email(email: str, token: str):
with open("app/email/templates/reset_password_email.html") as f:
html = Template(f.read()).render(token=token)
message = MessageSchema(
subject="Reset Password",
recipients=[email],
body=html,
subtype="html"
)
await FastMail(config).send_message(message)
- The email template is a separate HTML file, making it easy to update without changing code.
- You load the template, render it with the token, and create a message with HTML content.
- The
FastMailclient sends the message asynchronously.
This approach keeps your email logic reusable and testable. You can extend this with multiple templates, different email types, or localization, all without touching your route or service code.
🔄 routers/: Domain-Driven Routing
FastAPI’s routing system is flexible, but putting all routes in one file soon becomes unmanageable.
Instead, organize routes by domain or feature. For example:
routers/
├── auth/
├── payments/
├── core/
Inside each domain folder, split code by responsibility:
auth.py: Contains the FastAPI route handlers (endpoints).services.py: Business logic like token creation or password verification.models.py: SQLAlchemy models specific to this domain.validators.py: Custom Pydantic validation logic.tasks.py: Background jobs related to the domain.oauth_providers.py: Logic for external OAuth integrations.
This layout:
- Keeps all auth-related code in one place.
- Makes testing easier, because each domain’s logic is scoped.
- Helps new developers quickly find and understand domain boundaries.
🔒 services.py: Core Logic, Not in Routes
Your API routes should be thin and delegate heavy lifting to service functions. This keeps your endpoints focused on HTTP concerns (like request/response) and lets you test business logic independently.
Here’s a bad example:
@app.post("/users")
async def register_user(email: str, password: str):
# hash password, create db user, send email...
This mixes password hashing, database calls, and email sending all inside the endpoint, making it hard to read, test, or reuse.
A better approach:
@app.post("/users")
async def register_user(data: UserCreate):
return await create_user(data)
Now, create_user() lives in services.py. This function handles the core user creation logic, isolated from the HTTP layer. You can write unit tests for it without needing to simulate HTTP requests, improving reliability and maintainability.
⚖️ When It’s Okay to Keep Logic in Endpoints
While the “thin routes, fat services” principle is a solid best practice, it is perfectly fine to keep some logic directly in your FastAPI endpoints when:
- The logic is trivial or only a few lines, such as simple parameter checks or straightforward conditionals.
- The operation is specific to HTTP, like returning a custom status code or shaping a response.
- You want to keep service functions reusable and generic, leaving HTTP-specific behavior to endpoints.
- The logic depends on request-bound context or dependencies that don’t make sense outside the route.
Example:
@app.get("/ping")
async def ping():
return {"status": "ok"}
Or simple validation tied to the request:
@app.post("/submit")
async def submit(data: DataSchema):
if not data.accept_terms:
raise HTTPException(status_code=400, detail="You must accept terms")
result = await process_submission(data)
return result
The key is to balance clarity and separation without over-engineering. Extract complex business and database logic into services, but allow HTTP-specific, small logic to live comfortably inside your endpoints.
✅ validators.py: Custom Input Checks
Pydantic handles most validation beautifully. But for more complex or reusable rules, it’s best to isolate them in a validators.py file within the domain.
Example:
def validate_password_strength(password: str):
if len(password) < 8:
raise ValueError("Password too short")
if not any(char.isdigit() for char in password):
raise ValueError("Password must include a number")
You can then import and use this validator in your Pydantic models or call it directly in your services or routes.
This pattern:
- Encourages DRY (don’t repeat yourself) code.
- Makes validation logic easier to test and maintain.
- Improves readability by moving complex checks out of model definitions.
🧪 tests/ Inside Each Domain
Instead of dumping all your tests in one folder, put them alongside the feature code they cover.
For example:
routers/
└── auth/
├── auth.py
├── services.py
├── tests/
│ └── test_auth.py
This organization helps by:
- Keeping tests close to the source code, so changes and tests stay synchronized.
- Making it easier to navigate and understand tests when debugging or adding features.
- Supporting modular test runs targeting only specific domains, which speeds up your CI pipeline.
Use FastAPI’s built-in TestClient and tools like pytest to mock dependencies and isolate units of logic for reliable tests.
🧼 Bonus Best Practices
Here are some additional tips for building scalable FastAPI apps that fit well with this modular structure:
| Tip | Why It Helps |
|---|---|
Use API versioning (/api/v1) | Allows smooth introduction of breaking changes without disrupting existing clients. |
| Add pre-commit hooks (Black, Ruff) | Enforces consistent code style and catches errors early. |
| Configure logging early | Makes debugging in production environments easier and faster. |
| Isolate business logic | Keeps code testable, reusable, and easier to maintain. |
| Use Alembic for migrations | Ensures controlled and repeatable database schema changes. |
| Add background workers (e.g. Celery) | Offloads slow or heavy tasks from the main request cycle. |
| Automate with Makefiles or scripts | Simplifies developer onboarding and common tasks. |
🛠️ TL;DR: Use a Proven Structure
This structure isn’t just theory. It’s battle-tested by production teams and implemented by projects like FastLaunchAPI.dev.
Starting your project with this modular layout means:
- Clear separation of concerns
- Ready-to-use OAuth and JWT authentication
- Async database sessions out of the box
- Email sending and templating support
- Scoped tests to boost confidence
Try it yourself: https://fastlaunchapi.dev
🧠 Final Thoughts
Even if you’re a solo developer building a small app, invest in your project structure early. It will:
- Save you time later as your app grows
- Make testing and debugging easier
- Make it simpler for others to contribute
- Help you maintain sanity during complex feature additions
Good architecture is not about rigid boilerplate. It’s about clarity and ease of understanding.
When your code is clean, everything else becomes easier.
❓ FAQ (for SEO and Clarity)
Q: Which logic belongs in services versus endpoints? A: Complex business rules, database operations, and interactions with external services should live in service functions. Endpoints should focus on HTTP concerns like request validation, authorization checks, and shaping the response. Simple, HTTP-specific logic can remain in the endpoint for clarity.
Q: Should I always use asynchronous code with FastAPI? A: Use async for I/O-bound operations such as database queries, external API calls, or sending emails. Sync code is fine for CPU-bound or trivial tasks but beware of blocking the event loop in async contexts.
Q: How important is API versioning?
A: API versioning is crucial for production applications. It lets you introduce changes or improvements without breaking existing clients. Common practice is to prefix routes with /api/v1.
Q: Can I use this structure for small projects? A: Yes. Even small projects benefit from modular code organization. It helps maintain clarity and makes future scaling easier.
Q: How do I test FastAPI endpoints effectively?
A: Use FastAPI’s TestClient combined with pytest. Scope your tests close to your domain modules and mock dependencies to isolate the business logic for unit testing.
Related Articles
Modern API Design: Balancing Speed, Maintainability, and Developer Experience
A deep dive into best practices for designing APIs today, including async patterns, structured code, deployment strategies, and performance tradeoffs—plus a subtle nod to FastAPI for rapid development.
From Zero to Production: Launching Your First SaaS with FastAPI in 30 Days
A practical roadmap for solo founders to go from idea to live SaaS in a month using FastAPI — simple, fast, and realistic.
How I Automated My Side Hustle with FastAPI (and Made My First $500)
A personal story of how I used FastAPI to turn repetitive freelance work into a simple automation tool that started earning money on its own.