DEV Community

patrykkomow12
patrykkomow12

Posted on Originally published at getpylaunch.com

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.

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)]
Enter fullscreen mode Exit fullscreen mode

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)])
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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, db and i18n sit at the bottom.
  • Dependencies point one way between features. billing imports from accounts, because a subscription belongs to a user. accounts never imports from billing. admin and gdpr sit 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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Checklist for a new feature

  • A package under app/ with models.py, service.py and routes.py.
  • The model imported in app/models.py; a migration generated, read and applied.
  • Foreign keys to users.id that 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.py and .env.example, read through get_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.

Top comments (0)