VOOZH about

URL: https://dev.to/bedram-tamang/how-to-structure-a-fastapi-application-4l40

⇱ How to Structure a FastAPI Application - DEV Community


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

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
# 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"

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

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
# 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

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

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)

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")

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]

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

Run it:

uvicorn main:fastapi --reload

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)

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,
 ]
 )

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

LOG_LEVEL=DEBUG uvicorn main:fastapi --reload

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,
 ]
 )

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

Query it anywhere:

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

Run migrations from the terminal:

uv run artisan migrate
uv run artisan make:model Post
uv run artisan make:migration create_posts_table

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

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