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.
Top comments (1)
I learned something new today. Thank you ♥️