DEV Community

Cover image for How to Structure a FastAPI Application
Bedram Tamang
Bedram Tamang

Posted on

How to Structure a FastAPI Application

FastAPI is one of the best Python web frameworks available today — fast, async-native, and backed by excellent tooling. But when you move beyond a single main.py file, you quickly realize that FastAPI gives you the engine, not the car. Structuring a production-ready application is entirely up to you.

Let's walk through what that looks like in practice.


Starting from Scratch

1. Project Layout

A typical "real" FastAPI project ends up looking something like this:

my_app/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── config.py
│   ├── database.py
│   ├── logger.py
│   ├── dependencies.py
│   ├── routers/
│   │   ├── __init__.py
│   │   ├── users.py
│   │   └── posts.py
│   └── models/
│       ├── __init__.py
│       ├── user.py
│       └── post.py
├── tests/
├── .env
├── .env.production
├── pyproject.toml
└── README.md
Enter fullscreen mode Exit fullscreen mode

Already a lot of scaffolding — and we haven't written a single route yet.


2. Loading Environment Variables

FastAPI has no built-in env loading. You reach for python-dotenv:

pip install python-dotenv
Enter fullscreen mode Exit fullscreen mode
# app/config.py
import os
from dotenv import load_dotenv

load_dotenv()
env = os.environ.get("APP_ENV", "production")
if env != "production":
    load_dotenv(f".env.{env}", override=True)

DATABASE_URL = os.getenv("DATABASE_URL")
SECRET_KEY   = os.getenv("SECRET_KEY")
DEBUG        = os.getenv("DEBUG", "false").lower() == "true"
Enter fullscreen mode Exit fullscreen mode

But now every config value is a raw string. You need to cast types yourself, handle missing keys yourself, and figure out how to share this across modules without circular imports.

Some teams reach for Pydantic's BaseSettings:

# app/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    secret_key: str
    debug: bool = False
    redis_host: str = "localhost"
    redis_port: int = 6379

    class Config:
        env_file = ".env"

settings = Settings()
Enter fullscreen mode Exit fullscreen mode

Better — but now you have two config systems if you need per-environment overrides, and no clean way to namespace configs (database.host vs cache.host).


3. Setting Up the Database

pip install sqlalchemy asyncpg alembic
Enter fullscreen mode Exit fullscreen mode
# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase

engine = create_async_engine(settings.database_url, echo=settings.debug)

AsyncSessionLocal = sessionmaker(
    engine, class_=AsyncSession, expire_on_commit=False
)

class Base(DeclarativeBase):
    pass

async def get_db():
    async with AsyncSessionLocal() as session:
        yield session
Enter fullscreen mode Exit fullscreen mode

Then you wire that into every route as a dependency:

# app/routers/users.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db

router = APIRouter()

@router.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(User))
    return result.scalars().all()
Enter fullscreen mode Exit fullscreen mode

Every route needs that Depends(get_db). Every model needs to know about Base. Alembic needs its own env.py wired to your engine. Migrations are a separate manual step to configure.


4. Configuring Logging

FastAPI has no built-in logging configuration. There's no framework opinion — you wire it yourself:

# app/logger.py
import logging
import sys

def configure_logging(debug: bool = False):
    level = logging.DEBUG if debug else logging.INFO

    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(logging.Formatter(
        "%(asctime)s — %(name)s — %(levelname)s — %(message)s"
    ))

    root = logging.getLogger()
    root.setLevel(level)
    root.addHandler(handler)

    logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
    logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
Enter fullscreen mode Exit fullscreen mode

Then call it at startup:

# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.logger import configure_logging
from app.config import settings
from app.routers import users, posts

@asynccontextmanager
async def lifespan(app: FastAPI):
    configure_logging(debug=settings.debug)
    yield

app = FastAPI(lifespan=lifespan)
app.include_router(users.router, prefix="/api")
app.include_router(posts.router, prefix="/api")
Enter fullscreen mode Exit fullscreen mode

5. Dependency Injection — Roll Your Own

FastAPI's Depends() system is powerful but low-level. Need to inject a service across dozens of routes? You'll write a factory function, register it, and thread it through every handler manually. There's no service container. No provider pattern. No automatic resolution.


6. CLI / Management Commands

Need to run migrations? Seed the database? Clear a cache? FastAPI has no CLI. You'll integrate Alembic, write custom Click commands, wire them to a Makefile or shell scripts, and document everything yourself.


The Honest Summary

By the time you've wired up environment loading, database connections, migrations, logging, dependency injection, CLI commands, and a sensible folder structure — you've built a mini-framework on top of FastAPI.

And you'll do it again for the next project. And the one after that.


There's a Better Way

FastAPI Startkit is a Laravel/Masonite-inspired framework that replaces all of that manual wiring. Let's rebuild the same app — one layer at a time.


Step 1 — A running FastAPI app in one file

Install it:

uv add fastapi-startkit[fastapi]
Enter fullscreen mode Exit fullscreen mode

That's the only dependency you need to start. Here's the minimal main.py:

from pathlib import Path
from fastapi_startkit import Application
from fastapi_startkit.fastapi import FastAPIProvider

app: Application = Application(
    base_path=Path(__file__),
    providers=[
        FastAPIProvider,
    ]
)

fastapi = app.fastapi
Enter fullscreen mode Exit fullscreen mode

Run it:

uvicorn main:fastapi --reload
Enter fullscreen mode Exit fullscreen mode

Your app is live. No lifespan, no manual FastAPI() instantiation, no setup boilerplate.


Step 2 — Add routes

app.fastapi is a standard FastAPI instance — use FastAPI's own APIRouter exactly as you already know:

  from pathlib import Path
+ from fastapi import APIRouter
  from fastapi_startkit import Application
  from fastapi_startkit.fastapi import FastAPIProvider

  app: Application = Application(
      base_path=Path(__file__),
      providers=[
          FastAPIProvider,
      ]
  )

  fastapi = app.fastapi

+ router = APIRouter()
+
+ @router.get("/users")
+ async def list_users():
+     return [{"id": 1, "name": "Alice"}]
+
+ @router.get("/users/{user_id}")
+ async def show_user(user_id: int):
+     return {"id": user_id, "name": "Alice"}
+
+ fastapi.include_router(router)
Enter fullscreen mode Exit fullscreen mode

No new API to learn. It's just FastAPI.


Step 3 — Add logging

Swap the standard logging setup for LogProvider. One line:

  from pathlib import Path
  from fastapi_startkit import Application
  from fastapi_startkit.fastapi import FastAPIProvider
+ from fastapi_startkit.logging import LogProvider

  app: Application = Application(
      base_path=Path(__file__),
      providers=[
+         LogProvider,
          FastAPIProvider,
      ]
  )
Enter fullscreen mode Exit fullscreen mode

Logging is now configured and active. Change the level with an env var — no code change required:

LOG_LEVEL=DEBUG uvicorn main:fastapi --reload
Enter fullscreen mode Exit fullscreen mode

Step 4 — Add the database

  from pathlib import Path
  from fastapi_startkit import Application
  from fastapi_startkit.fastapi import FastAPIProvider
  from fastapi_startkit.logging import LogProvider
+ from fastapi_startkit.masoniteorm import DatabaseProvider

  app: Application = Application(
      base_path=Path(__file__),
      providers=[
          LogProvider,
+         DatabaseProvider,
          FastAPIProvider,
      ]
  )
Enter fullscreen mode Exit fullscreen mode

Define a model — no Base, no DeclarativeBase, no session factory:

from fastapi_startkit.masoniteorm import Model

class User(Model):
    __table__ = "users"

    id: int
    name: str
    email: str
Enter fullscreen mode Exit fullscreen mode

Query it anywhere:

users = await User.all()
user  = await User.find(1)
await User.create({"name": "Alice", "email": "alice@example.com"})
Enter fullscreen mode Exit fullscreen mode

Run migrations from the terminal:

uv run artisan migrate
uv run artisan make:model Post
uv run artisan make:migration create_posts_table
Enter fullscreen mode Exit fullscreen mode

No Alembic. No custom env.py. No session dependency threaded through every route.


The full picture

Starting from nothing, here's the complete progression:

  from pathlib import Path
  from fastapi_startkit import Application
+ from fastapi_startkit.logging import LogProvider
+ from fastapi_startkit.masoniteorm import DatabaseProvider
  from fastapi_startkit.fastapi import FastAPIProvider

  app: Application = Application(
      base_path=Path(__file__),
      providers=[
+         LogProvider,
+         DatabaseProvider,
          FastAPIProvider,
      ]
  )

  fastapi = app.fastapi
Enter fullscreen mode Exit fullscreen mode

Three lines added. Logging, database, and a fully wired FastAPI instance — all ready.


Conclusion

FastAPI is an excellent foundation. But building around it — environment management, configuration, ORM, logging, CLI, dependency injection — takes real effort and tends to drift between projects.

FastAPI Startkit gives you that structure from day one, so you can focus on shipping features instead of plumbing.

→ Get started at fastapi-startkit.github.io

Top comments (2)

Collapse
 
backtobasics profile image
Adarsh Kumar Singh • • Edited

I learned something new today. Thank you ♥️

Some comments may only be visible to logged-in visitors. Sign in to view all comments.