Structuring a Flask/FastAPI SaaS Project

The essential playbook for implementing structuring a flask/fastapi saas project in your SaaS.

Intro

A SaaS project structure should make feature work fast without creating deployment and maintenance problems later. For Flask and FastAPI, the goal is the same: isolate framework setup, keep business logic out of route handlers, separate infrastructure concerns, and make auth, billing, jobs, and settings easy to test and replace.

This page gives a production-ready baseline for small SaaS apps that need to support auth, billing, background jobs, and deployment without turning into a rewrite project.

Quick Fix / Quick Setup

Use this as the default starter layout:

text
app/
  api/
    routes/
    deps/
    schemas/
  auth/
  billing/
  core/
    config.py
    security.py
    database.py
    logging.py
  models/
  services/
  repositories/
  tasks/
  templates/
  static/
  main.py
migrations/
tests/
  unit/
  integration/
  e2e/
.env.example
requirements.txt
Dockerfile

Use a feature-aware structure with a thin entrypoint, centralized config, separate models/services/repositories, and dedicated folders for auth, billing, tasks, and tests. This works for both Flask and FastAPI and scales better than keeping everything in a single app.py file.

Minimal rules:

  • main.py or wsgi.py/asgi.py should only create the app, register routes, middleware, and startup hooks.
  • core/config.py should load all environment variables.
  • core/database.py should own DB engine/session setup.
  • Route handlers should parse input, call services, and return output.
  • Business logic should live in services/.
  • Long-running work should live in tasks/.

What’s happening

Early SaaS projects often start with one file or a framework-default layout that mixes routes, database access, auth logic, and external API calls.

That works for an MVP, but it breaks down once you add:

  • subscriptions
  • webhooks
  • background jobs
  • email
  • file uploads
  • multiple environments
  • role checks
  • CI and production deployment

A good structure helps with:

  • fewer circular imports
  • thinner route handlers
  • cleaner tests
  • easier deployment
  • less framework lock-in
  • easier auth and billing changes

Prefer organizing around domain boundaries plus shared infrastructure, not just framework defaults.

Step-by-step implementation

1. Create a single app package

Keep all application code under one package:

text
app/

Do not spread source files across the repo root.

Recommended root layout:

text
app/
migrations/
tests/
scripts/
Dockerfile
requirements.txt
.env.example
README.md

2. Centralize config in core/config.py

Read environment variables once and expose validated settings.

FastAPI example with Pydantic settings:

python
# app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    APP_NAME: str = "my-saas"
    ENV: str = "development"
    DEBUG: bool = False

    DATABASE_URL: str
    REDIS_URL: str | None = None

    SECRET_KEY: str
    STRIPE_SECRET_KEY: str | None = None
    STRIPE_WEBHOOK_SECRET: str | None = None

    model_config = SettingsConfigDict(
        env_file=".env",
        extra="ignore"
    )


settings = Settings()

Flask example:

python
# app/core/config.py
import os


class Settings:
    APP_NAME = os.getenv("APP_NAME", "my-saas")
    ENV = os.getenv("ENV", "development")
    DEBUG = os.getenv("DEBUG", "false").lower() == "true"

    DATABASE_URL = os.environ["DATABASE_URL"]
    REDIS_URL = os.getenv("REDIS_URL")

    SECRET_KEY = os.environ["SECRET_KEY"]
    STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
    STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")


settings = Settings()

Keep .env.example updated:

env
APP_NAME=my-saas
ENV=development
DEBUG=true
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=change-me
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

For config and secrets handling, also see Environment Variables and Secrets Management.

3. Move DB setup into core/database.py

Keep engine/session code out of routes and models.

SQLAlchemy example:

python
# app/core/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.core.config import settings


class Base(DeclarativeBase):
    pass


engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

FastAPI dependency:

python
# app/api/deps/db.py
from app.core.database import SessionLocal

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Flask pattern:

python
# app/extensions.py
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker

engine = None
SessionLocal = None

def init_db(database_url: str):
    global engine, SessionLocal
    engine = create_engine(database_url, pool_pre_ping=True)
    SessionLocal = scoped_session(sessionmaker(bind=engine))

4. Keep the HTTP layer thin

Put routes in api/routes/ and keep handlers small.

FastAPI route example:

python
# app/api/routes/users.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps.db import get_db
from app.api.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user

router = APIRouter(prefix="/users", tags=["users"])

@router.post("/", response_model=UserOut)
def create_user_route(payload: UserCreate, db: Session = Depends(get_db)):
    user = create_user(db, payload)
    return user

Flask blueprint example:

python
# app/api/routes/users.py
from flask import Blueprint, request, jsonify
from app.services.user_service import create_user

bp = Blueprint("users", __name__, url_prefix="/users")

@bp.post("/")
def create_user_route():
    payload = request.get_json()
    user = create_user(payload)
    return jsonify({"id": user.id, "email": user.email}), 201

Handlers should do only this:

  • parse input
  • call a service
  • map output to response
  • return status code

5. Separate models from request/response schemas

Do not mix ORM models and API schemas in the same file.

Example:

text
app/
  models/
    user.py
    subscription.py
  api/
    schemas/
      user.py
      billing.py

FastAPI schema example:

python
# app/api/schemas/user.py
from pydantic import BaseModel, EmailStr

class UserCreate(BaseModel):
    email: EmailStr
    password: str

class UserOut(BaseModel):
    id: int
    email: EmailStr

ORM model example:

python
# app/models/user.py
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(unique=True, index=True)
    password_hash: Mapped[str]

6. Put business logic in services/

Move workflows out of routes.

python
# app/services/user_service.py
from app.models.user import User
from app.core.security import hash_password

def create_user(db, payload):
    user = User(
        email=payload.email,
        password_hash=hash_password(payload.password),
    )
    db.add(user)
    db.commit()
    db.refresh(user)
    return user

Typical service responsibilities:

  • registration
  • subscription sync
  • plan access checks
  • permission logic
  • file processing
  • email triggers
  • webhook processing

7. Add repositories/ only when needed

Do not force a repository layer too early. Add it when query logic gets repeated or hard to test.

python
# app/repositories/user_repository.py
from app.models.user import User

def get_user_by_email(db, email: str):
    return db.query(User).filter(User.email == email).first()

Use repositories when:

  • queries are complex
  • multiple services reuse the same DB access
  • you want cleaner test seams
  • route/service modules are filling with raw query code

8. Create dedicated feature modules

High-change SaaS features should have clear boundaries.

Recommended:

text
app/
  auth/
  billing/
  users/
  files/

Examples:

  • auth/: registration, login, password reset, session/JWT logic
  • billing/: Stripe checkout, customer sync, webhooks, subscription state
  • files/: upload validation, storage service, processing jobs

This keeps auth and billing from leaking into unrelated modules. For auth implementation details, see Implement User Authentication (Login/Register). For Stripe setup, see Stripe Subscription Setup (Step-by-Step).

9. Move background work into tasks/

Do not block HTTP requests with slow operations.

Use tasks/ for:

  • email sending
  • webhook retries
  • file processing
  • cleanup jobs
  • scheduled syncs
  • analytics exports

Example:

python
# app/tasks/billing_tasks.py
def process_stripe_webhook(event_payload: dict):
    # validate event
    # map to billing service
    # store result
    pass

Keep route handlers as dispatchers, not workers.

10. Isolate external integrations

Provider-specific code should not be scattered across routes.

Recommended layouts:

text
app/
  billing/
    stripe_client.py
    service.py
  integrations/
    email_client.py
    s3_client.py

Good pattern:

  • routes call services
  • services call provider adapters
  • provider adapters wrap SDK usage

Bad pattern:

  • routes directly call Stripe, S3, SMTP, OAuth, or analytics SDKs inline

11. Add centralized logging and error handling early

Create core/logging.py and standardize request logging, error formatting, and request IDs.

Example stub:

python
# app/core/logging.py
import logging

def configure_logging():
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s %(message)s"
    )

Add middleware or hooks for:

  • request IDs
  • structured logs
  • exception logging
  • timing
  • webhook event tracing
router
service
repository
database/provider

request flow diagram from router -> service -> repository -> database/provider.

12. Keep app startup minimal

FastAPI example:

python
# app/main.py
from fastapi import FastAPI
from app.api.routes import users
from app.core.logging import configure_logging

configure_logging()

app = FastAPI(title="my-saas")
app.include_router(users.router)

Flask example:

python
# app/main.py
from flask import Flask
from app.api.routes.users import bp as users_bp
from app.core.config import settings

def create_app():
    app = Flask(__name__)
    app.config["SECRET_KEY"] = settings.SECRET_KEY
    app.register_blueprint(users_bp)
    return app

app = create_app()

Entrypoints should not contain:

  • business logic
  • model definitions
  • webhook processing
  • raw SQL
  • environment parsing repeated inline

13. Add migrations from day one

Keep schema changes versioned:

text
migrations/

Typical commands:

bash
alembic revision --autogenerate -m "create users table"
alembic upgrade head
alembic current
alembic heads

14. Separate tests by scope

Recommended test layout:

text
tests/
  unit/
  integration/
  e2e/
  conftest.py

Use:

  • unit/ for pure service logic
  • integration/ for DB and API behavior
  • e2e/ for realistic app flows

Example commands:

bash
pytest -q
pytest tests/unit -q
pytest tests/integration -q

Do not let tests depend on production .env or shared databases.

15. Keep deployment artifacts in the repo, but outside app logic

Recommended files:

text
Dockerfile
docker-compose.yml
.env.example
scripts/

Minimal Dockerfile example:

dockerfile
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Keep infrastructure configuration separate from runtime app code.

Common causes

Common structure problems in Flask/FastAPI SaaS projects:

  • Keeping all Flask/FastAPI code in a single file until auth, billing, and jobs become tangled.
  • Mixing ORM models, request schemas, and response serialization in the same module.
  • Placing Stripe, email, or storage provider calls directly inside routes.
  • Using framework globals everywhere, making unit tests and CLI scripts hard to run.
  • No central config module, leading to duplicated environment parsing and inconsistent settings.
  • Circular imports caused by app creation, route registration, and model imports referencing each other.
  • Background jobs sharing request-layer code paths without clear service boundaries.

Debugging tips

Use these commands to inspect structure and common failure points:

bash
find . -maxdepth 3 -type f | sort
tree -L 3 app
grep -R "from app\|import app" app
grep -R "os.environ\|dotenv\|BaseSettings" app
pytest -q
pytest tests/unit -q
pytest tests/integration -q
uvicorn app.main:app --reload
flask --app app.main routes
alembic current && alembic heads

What to check:

  • If imports are failing, search for modules importing the app object directly.
  • If tests are brittle, search for os.environ reads spread across multiple files.
  • If route handlers are too large, move logic into services/.
  • If startup is slow or error-prone, reduce work done at import time.
  • If webhooks and async jobs reuse route code, split shared logic into service functions.

Framework-specific checks:

Flask

  • Use an application factory.
  • Keep extensions initialized outside request modules.
  • Avoid importing app into models or service code.

FastAPI

  • Keep dependencies request-scoped.
  • Avoid putting business workflows inside dependency functions.
  • Make router registration centralized.

directory tree diagram showing core, api, services, models, tasks, and feature modules.

📁app/Project root
📁core/Module
📁api/Module
📁services/Module
📁models/Module
📁tasks/Module

Checklist

  • Entrypoint is thin and framework setup is isolated.
  • Environment config is centralized and validated.
  • Auth, billing, and background jobs have dedicated modules.
  • Database models are separate from request/response schemas.
  • Route handlers are thin and call services.
  • External integrations are isolated behind helper modules or services.
  • Migrations, tests, and operational scripts have dedicated locations.
  • The structure supports local dev, CI, and production deployment consistently.
  • directory tree diagram showing core, api, services, models, tasks, and feature modules.

    📁app/Project root
    📁core/Module
    📁api/Module
    📁services/Module
    📁models/Module
    📁tasks/Module
    router
    service
    repository
    database/external provider

    request flow diagram from router -> service -> repository -> database/external provider.

For final deployment validation, use SaaS Production Checklist.

Related guides

FAQ

What is the safest starter structure for a Flask or FastAPI SaaS?

Use a single app/ package with core, api, models, services, feature modules like auth and billing, tasks, and tests. Keep the entrypoint thin.

Should I separate auth and billing early?

Yes. They change often, touch external systems, and benefit from clear boundaries even in small SaaS apps.

How do I avoid circular imports?

Use an app factory or central app creation module, keep shared infrastructure in core/, avoid importing app objects inside models/services, and register routers/blueprints in one place.

Where should environment settings live?

In a dedicated config module that reads environment variables once and exposes validated settings to the rest of the app.

Is this overkill for an MVP?

No. This is a minimal production-ready baseline. You can start with fewer modules, but keep the boundaries from day one.

Final takeaway

Use a modular monolith with:

  • a thin HTTP layer
  • centralized config
  • separated business logic
  • dedicated feature modules for auth, billing, and background jobs

This structure is simple enough for an MVP and stable enough to survive production deployment and team growth.