Reference: A FastAPI SaaS Backend
A reference layout for a multi-tenant B2B SaaS API: FastAPI 0.115+, Pydantic v2, Postgres, Redis, Celery workers, and observability baked in from day one - deployable on Kubernetes with flags and expand-contract migrations.
Recipe
Quick-reference recipe card - copy-paste ready.
acme-api/
src/acme_api/
main.py # FastAPI app factory
api/routes/ # HTTP routers
domain/ # business logic (framework-free)
infra/db.py # SQLAlchemy session
workers/tasks.py # Celery tasks
alembic/ # migrations
tests/
Dockerfile # Python 3.14 slim + uv syncWhen to reach for this:
- Greenfield SaaS backend
- Teaching new squad the fleet standard
- Comparing your layout to a baseline
- RFC appendix for greenfield ADR
Working Example
"""src/acme_api/main.py - minimal SaaS app factory."""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pydantic import BaseModel
from acme_api.api.routes import tenants, webhooks
from acme_api.infra.db import init_db
class Health(BaseModel):
status: str
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
def create_app() -> FastAPI:
app = FastAPI(title="Acme API", version="1.0.0", lifespan=lifespan)
app.include_router(tenants.router, prefix="/v1/tenants")
app.include_router(webhooks.router, prefix="/v1/webhooks")
@app.get("/health/ready", response_model=Health)
def ready() -> Health:
return Health(status="ok")
return app
app = create_app()"""src/acme_api/domain/billing.py - framework-free logic."""
from __future__ import annotations
from decimal import Decimal
def compute_total(subtotal: Decimal, tax_rate: Decimal) -> Decimal:
return (subtotal * (Decimal("1") + tax_rate)).quantize(Decimal("0.01"))"""src/acme_api/workers/tasks.py"""
import os
from celery import Celery
celery_app = Celery("acme", broker=os.getenv("REDIS_URL", "redis://localhost:6379/0"))
@celery_app.task(bind=True, max_retries=5)
def send_invoice_email(self, invoice_id: str) -> None:
...Architecture diagram (logical):
Client → Ingress → FastAPI (Gunicorn/Uvicorn workers)
↓ SQLAlchemy
Postgres
↓ enqueue
Redis → Celery workers → S3 / emailWhat this demonstrates:
- Domain package has no FastAPI imports - testable core
- Routers thin; Pydantic models at boundaries
- Celery handles async side effects
- Health endpoint for Kubernetes readiness
Deep Dive
How It Works
- Multi-tenancy -
tenant_idon rows or schema-per-tenant; middleware sets context var. - Auth - JWT or session via dependency injection; scopes per route.
- Migrations - Alembic expand-contract; run in release job before traffic.
- Observability - structlog JSON + OpenTelemetry FastAPI instrumentation.
- Deploy - Single image for API; worker image shares lockfile; tag
git sha.
Stack Choices
| Layer | Choice | Why |
|---|---|---|
| API | FastAPI 0.115+ | OpenAPI, Pydantic native |
| DB | Postgres 16 + SQLAlchemy 2 | Mature, async option later |
| Queue | Celery + Redis | Retries, DLQ patterns |
| Config | pydantic-settings | Validated boot |
| Tooling | uv 0.6+ | Fast CI, locked deps |
Python Notes
FROM python:3.14.0-slim
COPY uv.lock pyproject.toml ./
RUN pip install uv && uv sync --frozen --no-dev
COPY src/ ./src/
CMD ["gunicorn", "acme_api.main:app", "-k", "uvicorn.workers.UvicornWorker"]Gotchas
- Fat route handlers - Untestable monolith in files. Fix: domain services + thin routers.
- Shared ORM models in workers without contract tests - Schema drift. Fix: Pydantic job payloads versioned.
- Missing tenant guard on queries - Data leak. Fix: Repository layer requires tenant_id.
- Sync DB in async routes without plan - Loop block later. Fix: Document sync choice; thread pool for CPU.
- No idempotency on webhooks - Duplicate side effects. Fix: Idempotency-Key store.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Django + DRF | Admin-heavy CRM | API-only microservice |
| Split microservices early | Independent scale proven | Team <8 |
| Serverless Lambda | Spiky low traffic | Long Celery-style jobs |
| Flask | Minimal legacy | Greenfield OpenAPI-first |
FAQs
When split workers to separate repo?
When release cadence or failure domain diverges; until then monorepo packages.
Async SQLAlchemy day one?
Optional; sync + clear thread offload is fine at moderate RPS.
How handle multi-region?
Read replicas, tenant data residency flags, replicate object storage.
Billing integration?
Stripe webhooks with idempotency table; Celery for fulfillment.
Testing strategy?
pytest unit on domain; TestClient integration; contract tests for workers.
Feature flags?
pydantic-settings env flags; migrate to vendor when PM toggles needed.
API versioning?
URL /v1 prefix; breaking changes get /v2 with sunset policy.
Secrets?
SSM/External Secrets; never in image layers.
Rate limiting?
Redis token bucket middleware per tenant tier.
Compare to Node reference?
Same modular monolith discipline; Python wins on data/ML adjacency.
Related
- Reference: A Production ML Pipeline - ML adjacency
- Before/After: Sync to Async Migration - scale path
- FastAPI Basics - framework depth
- Dockerizing Python - image build
- Delivery Best Practices - ship safely
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.