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 (1)

Collapse
 
backtobasics profile image
Adarsh Kumar Singh • Edited

I learned something new today. Thank you ♥️