Back to Blog
fastapi
python
web-dev
architecture
api-development

How to Structure a Scalable FastAPI Project

Learn the best practices for organizing FastAPI apps with a maintainable, scalable architecture.

Niklas L.
11 min read

🧱 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.


✅ 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:

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

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)

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:

This layout:


🔒 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:

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:


🧪 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:

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:

TipWhy 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 earlyMakes debugging in production environments easier and faster.
Isolate business logicKeeps code testable, reusable, and easier to maintain.
Use Alembic for migrationsEnsures 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 scriptsSimplifies 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:

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:

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