Backend Architecture

Learn about Starterbase's backend architecture, the 3-file module pattern, and how everything fits together.

Overview

The backend is built with FastAPI and follows a clean, modular architecture that separates concerns and makes the codebase easy to navigate and extend.

Tech Stack

Technology Purpose
FastAPI Modern Python web framework with automatic OpenAPI docs
SQLAlchemy 2.0 Async ORM with Mapped type annotations
Pydantic Data validation and serialization
Alembic Database migrations
asyncpg Async PostgreSQL driver
bcrypt Password hashing
PyJWT JWT token handling
structlog Structured logging
pytest Testing framework

Project Structure

backend/
├── app/
│   ├── api/
│   │   └── v1/              # API version 1
│   │       ├── router.py    # Main router aggregator
│   │       ├── auth/        # Authentication module
│   │       ├── billing/     # Stripe billing module
│   │       ├── organizations/ # Organization management
│   │       ├── settings/    # User settings
│   │       ├── health/      # Health check endpoint
│   │       ├── mcp/         # MCP server module
│   │       └── admin/       # Admin panel module
│   │           ├── analytics/
│   │           ├── users/
│   │           ├── roles/
│   │           ├── organizations/
│   │           ├── billing/
│   │           ├── subscriptions/
│   │           ├── products/
│   │           ├── invitations/
│   │           └── settings/
│   ├── controllers/         # Shared business logic
│   │   ├── auth.py          # JWT, password hashing, token blacklist
│   │   ├── email.py         # Email sending (Resend)
│   │   ├── tokens.py        # Token utilities
│   │   └── totp.py          # 2FA TOTP operations
│   ├── core/                # Core functionality
│   │   ├── config.py        # Configuration (Pydantic Settings)
│   │   ├── database.py      # Database connection and session
│   │   ├── exceptions.py    # Custom HTTP exceptions
│   │   ├── middleware.py    # Correlation ID, request logging
│   │   ├── logging.py       # Structured logging (structlog)
│   │   ├── redis_client.py  # Redis connection
│   │   ├── pagination.py    # Pagination utilities
│   │   ├── filtering.py     # Filter parsing utilities
│   │   ├── datetime.py      # Timezone-aware datetime helpers
│   │   └── responses.py     # Standard API responses
│   ├── models/              # SQLAlchemy models
│   │   ├── base.py          # Base model class
│   │   ├── user.py          # User model
│   │   ├── role.py          # Role model
│   │   ├── organization.py  # Organization model
│   │   ├── subscription.py  # Subscription model
│   │   ├── plan.py          # Billing plan model
│   │   ├── product.py       # One-time product model
│   │   ├── purchase.py      # Purchase record model
│   │   ├── invitation.py    # User invitation model
│   │   ├── password_reset.py # Password reset token model
│   │   ├── trusted_device.py # 2FA trusted device model
│   │   └── enums/           # Enum types
│   └── main.py              # FastAPI application entry point
├── alembic/                 # Database migrations
│   ├── versions/            # Migration files
│   └── env.py               # Alembic configuration
├── tests/                   # Test suite
├── pyproject.toml           # Python dependencies (uv)
└── .env                     # Environment variables

The 3-File Module Pattern

Every API module follows a consistent 3-file pattern:

app/api/v1/<module>/
├── router.py       # FastAPI routes (HTTP layer)
├── deps.py         # Dependencies + business logic
└── schemas.py      # Pydantic models (validation)

File Responsibilities

schemas.py - Data Validation

Defines Pydantic models for request/response validation:

from pydantic import BaseModel, EmailStr
from datetime import datetime
from uuid import UUID

class UserCreate(BaseModel):
    """Request schema for creating a user."""
    email: EmailStr
    password: str
    first_name: str | None = None

class UserResponse(BaseModel):
    """Response schema for user data."""
    id: UUID
    email: str
    first_name: str | None
    created_at: datetime

    model_config = {"from_attributes": True}

Guidelines:

deps.py - Business Logic

Contains business logic with direct database access:

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.exceptions import BadRequestException, NotFoundException
from app.models.user import User
from .schemas import UserCreate

async def create_user(db: AsyncSession, data: UserCreate) -> User:
    """Create a new user."""
    # Check if email already exists
    stmt = select(User).where(User.email == data.email)
    result = await db.execute(stmt)
    if result.scalar_one_or_none():
        raise BadRequestException("Email already registered")

    # Create user
    user = User(
        email=data.email,
        password_hash=hash_password(data.password),
        first_name=data.first_name,
    )
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return user

async def get_user_by_id(db: AsyncSession, user_id: UUID) -> User | None:
    """Get user by ID."""
    return await db.get(User, user_id)

Guidelines:

router.py - HTTP Layer

Defines FastAPI routes and delegates to deps functions:

from typing import Annotated
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.core.exceptions import NotFoundException
from . import deps
from .schemas import UserCreate, UserResponse

router = APIRouter()

@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
    data: UserCreate,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new user."""
    return await deps.create_user(db, data)

@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
    user_id: UUID,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Get user by ID."""
    user = await deps.get_user_by_id(db, user_id)
    if not user:
        raise NotFoundException("User not found")
    return user

Guidelines:

Why This Pattern?

Shared Logic: Controllers

For logic used across multiple modules, use the controllers/ directory:

# app/controllers/auth.py
import bcrypt
import jwt
from uuid import UUID, uuid4
from datetime import timedelta

from app.core.config import settings
from app.core.datetime import now

def hash_password(password: str) -> str:
    """Hash a password using bcrypt."""
    salt = bcrypt.gensalt()
    return bcrypt.hashpw(password.encode(), salt).decode()

def verify_password(password: str, hashed: str) -> bool:
    """Verify a password against its hash."""
    return bcrypt.checkpw(password.encode(), hashed.encode())

def create_access_token(user_id: UUID, email: str) -> str:
    """Create a JWT access token."""
    expire = now() + timedelta(minutes=settings.jwt_access_token_expire_minutes)
    payload = {
        "sub": str(user_id),
        "email": email,
        "exp": expire,
        "iat": now(),
        "jti": str(uuid4()),
        "type": "access",
    }
    return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)

Use controllers for:

Database Operations

Async SQLAlchemy 2.0

All database operations use async/await with modern Mapped syntax:

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

async def get_users(db: AsyncSession, skip: int = 0, limit: int = 10):
    """Get paginated users."""
    stmt = select(User).offset(skip).limit(limit)
    result = await db.execute(stmt)
    return result.scalars().all()

Session Management

Database sessions are managed via dependency injection:

from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db

@router.get("/users")
async def list_users(
    db: Annotated[AsyncSession, Depends(get_db)],
):
    # db session is automatically created and closed
    return await get_users(db)

Model Definitions

Models use SQLAlchemy 2.0 Mapped annotations:

from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.models.base import Base
from app.core.datetime import now

class User(Base):
    __tablename__ = "users"

    id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
    role_id: Mapped[UUID | None] = mapped_column(ForeignKey("roles.id"), nullable=True)
    created_at: Mapped[datetime] = mapped_column(default=now)

    # Relationships
    role: Mapped["Role | None"] = relationship("Role", lazy="joined")

Loading Relationships

Use selectinload for eager loading:

from sqlalchemy.orm import selectinload

stmt = select(User).options(selectinload(User.role)).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()

Configuration

Configuration is managed via Pydantic Settings with lowercase variable names:

# app/core/config.py
from pydantic import ConfigDict
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    model_config = ConfigDict(extra="ignore", env_file=".env")

    # Application
    app_name: str = "Starterbase"
    environment: str = "development"
    debug: bool = True
    frontend_url: str = "http://localhost:3000"

    # Database
    db_url: str = "postgresql+asyncpg://user:pass@localhost/db"

    # Redis
    redis_url: str = "redis://localhost:6379"

    # JWT
    jwt_secret: str = "change-this-in-production"
    jwt_algorithm: str = "HS256"
    jwt_access_token_expire_minutes: int = 15
    jwt_refresh_token_expire_days: int = 7

    # Stripe
    stripe_secret_key: str = ""
    stripe_publishable_key: str = ""

    @property
    def stripe_is_configured(self) -> bool:
        return bool(self.stripe_secret_key and self.stripe_publishable_key)

settings = Settings()

Access settings anywhere:

from app.core.config import settings

print(settings.db_url)
if settings.stripe_is_configured:
    # Use Stripe API

Middleware

CorrelationIdMiddleware

Adds X-Correlation-ID header to all requests for distributed tracing:

class CorrelationIdMiddleware:
    """Adds correlation IDs to requests for tracing."""

    HEADER_NAME = "X-Correlation-ID"

    async def __call__(self, scope, receive, send):
        # Extract or generate correlation ID
        correlation_id = extract_header(scope) or str(uuid.uuid4())

        # Bind to structlog context
        structlog.contextvars.bind_contextvars(correlation_id=correlation_id)

        # Add to response headers
        async def send_with_id(message):
            if message["type"] == "http.response.start":
                headers = list(message.get("headers", []))
                headers.append((self.HEADER_NAME.encode(), correlation_id.encode()))
                message["headers"] = headers
            await send(message)

        await self.app(scope, receive, send_with_id)

RequestLoggingMiddleware

Logs all requests/responses with timing:

class RequestLoggingMiddleware(BaseHTTPMiddleware):
    """Logs request/response details with timing."""

    async def dispatch(self, request, call_next):
        start_time = time.perf_counter_ns()
        response = await call_next(request)
        duration_ms = (time.perf_counter_ns() - start_time) / 1_000_000

        self.logger.info(
            f"{request.method} {request.url.path} {response.status_code}",
            status_code=response.status_code,
            duration_ms=round(duration_ms, 2),
        )

        response.headers["X-Process-Time-Ms"] = str(round(duration_ms, 2))
        return response

Exception Handling

Custom exceptions extend HTTPException for automatic handling:

# app/core/exceptions.py
from fastapi import HTTPException, status

class BadRequestException(HTTPException):
    def __init__(self, detail: str = "Bad request"):
        super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)

class UnauthorizedException(HTTPException):
    def __init__(self, detail: str = "Unauthorized"):
        super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)

class ForbiddenException(HTTPException):
    def __init__(self, detail: str = "Forbidden"):
        super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)

class NotFoundException(HTTPException):
    def __init__(self, detail: str = "Not found"):
        super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)

class ConflictException(HTTPException):
    def __init__(self, detail: str = "Conflict"):
        super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)

Usage:

from app.core.exceptions import NotFoundException, BadRequestException

async def get_user(db: AsyncSession, user_id: UUID) -> User:
    user = await db.get(User, user_id)
    if not user:
        raise NotFoundException("User not found")
    return user

No custom exception handlers needed - FastAPI handles HTTPException automatically.

Application Startup

The application uses FastAPI's lifespan for startup/shutdown:

# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    logger.info("Application starting")
    if settings.debug:
        async with engine.begin() as conn:
            await conn.run_sync(Base.metadata.create_all)
        await create_default_roles()

    await get_redis()  # Initialize Redis

    yield

    # Shutdown
    await close_redis()
    await engine.dispose()

app = FastAPI(title=settings.app_name, lifespan=lifespan)

# Middleware (order matters)
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(CorrelationIdMiddleware)

app.include_router(api_router, prefix="/api/v1")

Next Steps