How to structure a FastAPI project: feature packages, services and routers
FastAPI doesn't tell you how to lay out a project, and the single-file example from the docs stops being enough around the third feature. Here is a layout that keeps growing without a rewrite: one package per feature, thin routes, plain functions for the logic, and a few rules about who may import whom.
Contents
Group by feature, not by file type
Many FastAPI templates start with folders named after file types: routers/, models/, schemas/, crud/. It looks tidy with one feature. With ten, changing billing means opening four folders, each holding files about nine other things, and nothing shows what belongs together.
Grouping by feature inverts that. Everything about billing lives in app/billing/, and a new feature is a new folder you can read from top to bottom. It also gives you a useful test of the design: if a change to one feature keeps forcing edits in another feature's package, the boundary between them is in the wrong place.
The whole tree
app/
main.py app factory: middleware, sessions, routers, error redirects
config.py all settings, read from .env (pydantic-settings)
db.py async SQLAlchemy engine, sessions, Base for models
models.py imports every model (for Alembic and the tests)
web.py CSRF protection, flash messages, safe redirects
i18n.py translations and language detection
templating.py shared Jinja2 environment
email/ send_template(): HTML + text emails, SMTP or Resend
accounts/ sign-up, login, email verification, password reset, JWT
billing/ Stripe Checkout, subscriptions, portal, webhooks, EU VAT
admin/ admin panel: users, subscriptions, manual plans
gdpr/ data export, account deletion, cookie consent
routes/ pages that belong to no feature: home, /health, dashboard
locales/ en.json, pl.json: every UI text
templates/ Jinja2 templates, one folder per feature
static/ generated CSS, htmx.min.js
migrations/ Alembic migrations
tests/ one folder per feature, plus shared fixtures
deploy/ production Caddyfile and backup script
The top-level files in app/ are the shared foundation: settings, database, web helpers. Everything else is a feature, and features are what you add.
One feature, three files
A feature is usually models.py, service.py and routes.py. Take a small one: signed-in users keep short notes. The model comes first:
# app/notes/models.py
class Note(Base):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True)
# CASCADE: deleting an account deletes its notes too.
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
text: Mapped[str] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
Register it in app/models.py, a file whose only job is to import every model so that Alembic and the tests see all the tables. Then the logic, as plain async functions that take a session:
# app/notes/service.py
FREE_PLAN_LIMIT = 3
async def list_notes(session: AsyncSession, user: User) -> list[Note]:
result = await session.scalars(
select(Note).where(Note.user_id == user.id).order_by(Note.created_at.desc())
)
return list(result)
async def can_add_note(session: AsyncSession, user: User) -> bool:
"""Paid plans get unlimited notes, free users FREE_PLAN_LIMIT."""
if get_settings().billing_enabled and (await get_access(session, user)).plan:
return True
count = await session.scalar(select(func.count()).where(Note.user_id == user.id))
return count < FREE_PLAN_LIMIT
async def add_note(session: AsyncSession, user: User, text: str) -> Note:
note = Note(user_id=user.id, text=text)
session.add(note)
await session.commit()
return note
async def delete_note(session: AsyncSession, user: User, note_id: int) -> None:
# Filtering by user_id means nobody can delete someone else's note.
await session.execute(delete(Note).where(Note.id == note_id, Note.user_id == user.id))
await session.commit()
No repository classes, no unit-of-work object, no abstract base. SQLAlchemy's session already is a unit of work, and a function that takes one is trivial to call from anywhere and easy to test.
Then the routes, which read the request, call the service and choose a response:
# app/notes/routes.py
router = APIRouter(prefix="/notes", dependencies=[Depends(verify_csrf)])
@router.get("")
async def notes_page(request: Request, session: SessionDep, user: CurrentUser):
context = await _context(session, user)
return templates.TemplateResponse(request, "notes/index.html", context)
@router.post("/{note_id}/delete")
async def delete_note(request: Request, session: SessionDep, user: CurrentUser, note_id: int):
await service.delete_note(session, user, note_id)
context = await _context(session, user)
return templates.TemplateResponse(request, "notes/_list.html", context)
Include the router in the app factory, generate a migration, and the feature exists. Add a schemas.py only when the feature has a JSON API and needs Pydantic models. Don't create empty files just because other features have them.
Why the routes stay thin
The rule that matters most in this layout: logic goes in the service, not in the route. It pays off the first time a second caller appears. In a real product, accounts get created by the sign-up form, and also by tests. Users get signed out everywhere by the settings page, and also by an admin who deactivates the account:
# app/admin/routes.py
user.is_active = not user.is_active
await session.commit()
if not user.is_active:
await sign_out_everywhere(session, user) # also revokes API tokens
If sign_out_everywhere lived inside a route handler, the admin panel would have to copy it. As a service function it's one import. The same goes for a webhook that needs to send an email, or a command-line script that makes someone an admin:
# app/accounts/make_admin.py
async def set_admin(email: str, is_admin: bool) -> bool:
async with SessionLocal() as session:
user = await get_user_by_email(session, email)
if user is None:
return False
user.is_admin = is_admin
await session.commit()
return True
A useful check: if a function needs a Request, it belongs in routes. Services take the things they need (a session, a user, a string), which is also what keeps them callable without HTTP.
One module for settings
Every setting lives in one class, read from the environment and .env:
# app/config.py
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "MySaaS"
app_env: Literal["development", "test", "production"] = "development"
secret_key: str = "change-me-this-key-is-only-for-local-development"
# Public address of the app, used to build links in emails.
base_url: str = "http://localhost:8000"
database_url: str = "postgresql+asyncpg://app:app@localhost:5432/app"
@property
def is_production(self) -> bool:
return self.app_env == "production"
@lru_cache
def get_settings() -> Settings:
return Settings()
Pydantic converts and validates the values, so LOCALES=["en","pl"] in .env arrives as a list and a typo in APP_ENV stops the app at startup rather than at 3 a.m. Nothing else reads os.environ. To add a setting, add a field here and a line to .env.example, which doubles as the documentation.
lru_cache makes it one shared object, which tests can change for a single test with monkeypatch.setattr(get_settings(), "eu_mode", True).
The database session as a dependency
# app/db.py
async def get_session() -> AsyncIterator[AsyncSession]:
"""FastAPI dependency: one session per request."""
async with SessionLocal() as session:
yield session
# Use in route signatures: `async def view(session: SessionDep): ...`
SessionDep = Annotated[AsyncSession, Depends(get_session)]
The Annotated aliases are what keep route signatures readable. SessionDep, CurrentUser, VerifiedUser and ApiUser say what a route needs in one word each, and defining them once means a change to how users are loaded touches one place. Dependencies that apply to a whole router go on the router, so none of its routes can forget them:
router = APIRouter(prefix="/admin", dependencies=[Depends(verify_csrf), Depends(require_admin)])
An app factory that wires it together
# app/main.py
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title=settings.app_name,
# The interactive API docs are handy locally but shouldn't be public.
docs_url=None if settings.is_production else "/docs",
redoc_url=None,
openapi_url=None if settings.is_production else "/openapi.json",
# Puts the signed-in user (or None) on request.state.user for every route.
dependencies=[Depends(load_user)],
)
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key, same_site="lax")
@app.exception_handler(LoginRequiredError)
async def login_required(request: Request, exc: LoginRequiredError):
next_path = quote(request.url.path, safe="/")
return redirect(request, f"/login?next={next_path}")
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
app.include_router(accounts_routes.router)
app.include_router(billing_routes.router)
app.include_router(admin_routes.router)
app.include_router(gdpr_routes.router)
return app
app = create_app()
main.py should read like a table of contents and contain no business logic. It's the one module that imports every feature, which is exactly why no feature may import it.
Who may import whom
Circular imports are the usual way this kind of structure falls apart. A few rules prevent nearly all of them:
- Shared modules import no features.
config,dbandi18nsit at the bottom. - Dependencies point one way between features.
billingimports fromaccounts, because a subscription belongs to a user.accountsnever imports frombilling.adminandgdprsit on top and may import both. - Nothing imports
main, apart from the tests. - Reference tables by name across features.
ForeignKey("users.id")is a string, so a model doesn't need to import another model just to point at it.
When two features seem to need each other, one of them is usually doing the other's work, or a third module wants to exist. A User model that imports Subscription so it can answer "is this user paying?" is the classic example. Put the question in the billing service instead: get_access(session, user).
Put each external service behind one module
Every call to Stripe goes through app/billing/stripe_gateway.py, and every email through app/email/. The rest of the app never touches the Stripe SDK or smtplib:
# app/billing/stripe_gateway.py
"""Every call to the Stripe API goes through this module.
Functions return plain dicts (Stripe's JSON), so the rest of the app doesn't depend on
the Stripe SDK's object types, and tests can replace these functions with fakes.
"""
async def retrieve_subscription(subscription_id: str) -> dict:
return (await client().v1.subscriptions.retrieve_async(subscription_id)).to_dict()
This gives you one place to read every external call, one place to handle an SDK upgrade, and one place to fake in tests. It's the only abstraction in this layout that exists mainly for testing, and it earns it.
Tests that mirror the code
tests/
conftest.py test settings, database, shared fixtures
helpers.py act like a browser: forms with CSRF tokens, logging in, reading emails
fake_stripe.py in-memory stand-in for stripe_gateway
accounts/ test_login.py, test_register.py, test_reset_password.py, ...
billing/ test_checkout.py, test_webhooks.py, test_eu_vat.py
admin/ test_admin.py
gdpr/ test_gdpr.py
Most tests go through HTTP with the shared helpers, so they keep working when you move code between a route and a service. That's what makes refactoring this structure cheap: the tests pin behaviour, not file locations.
async def test_add_and_delete_notes(client, make_user):
await make_user()
await login(client)
response = await post_form(client, "/notes", {"text": "Call the accountant"})
assert "Call the accountant" in response.text
assert "<html" not in response.text # HTMX gets only the list
Checklist for a new feature
- A package under
app/withmodels.py,service.pyandroutes.py. - The model imported in
app/models.py; a migration generated, read and applied. - Foreign keys to
users.idthat say what happens on delete. - The router included in
create_app(), with shared dependencies on the router itself. - Logic in the service; routes that only translate between HTTP and service calls.
- New settings in
config.pyand.env.example, read throughget_settings(). - Imports pointing one way, and external services behind one module.
- Tests in a matching folder under
tests/.
This is the layout of getpylaunch, a FastAPI SaaS starter I sell, where accounts, billing, admin and GDPR are already built this way.