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:
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
DockerfileUse 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.pyorwsgi.py/asgi.pyshould only create the app, register routes, middleware, and startup hooks.core/config.pyshould load all environment variables.core/database.pyshould 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
- 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:
app/Do not spread source files across the repo root.
Recommended root layout:
app/
migrations/
tests/
scripts/
Dockerfile
requirements.txt
.env.example
README.md2. Centralize config in core/config.py
Read environment variables once and expose validated settings.
FastAPI example with Pydantic settings:
# 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:
# 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:
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_xxxFor 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:
# 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:
# app/api/deps/db.py
from app.core.database import SessionLocal
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()Flask pattern:
# 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:
# 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 userFlask blueprint example:
# 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}), 201Handlers 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:
app/
models/
user.py
subscription.py
api/
schemas/
user.py
billing.pyFastAPI schema example:
# app/api/schemas/user.py
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
id: int
email: EmailStrORM model example:
# 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.
# 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 userTypical 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.
# 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:
app/
auth/
billing/
users/
files/Examples:
auth/: registration, login, password reset, session/JWT logicbilling/: Stripe checkout, customer sync, webhooks, subscription statefiles/: 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:
# app/tasks/billing_tasks.py
def process_stripe_webhook(event_payload: dict):
# validate event
# map to billing service
# store result
passKeep route handlers as dispatchers, not workers.
10. Isolate external integrations
Provider-specific code should not be scattered across routes.
Recommended layouts:
app/
billing/
stripe_client.py
service.py
integrations/
email_client.py
s3_client.pyGood 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:
# 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
request flow diagram from router -> service -> repository -> database/provider.
12. Keep app startup minimal
FastAPI example:
# 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:
# 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:
migrations/Typical commands:
alembic revision --autogenerate -m "create users table"
alembic upgrade head
alembic current
alembic heads14. Separate tests by scope
Recommended test layout:
tests/
unit/
integration/
e2e/
conftest.pyUse:
unit/for pure service logicintegration/for DB and API behaviore2e/for realistic app flows
Example commands:
pytest -q
pytest tests/unit -q
pytest tests/integration -qDo not let tests depend on production .env or shared databases.
15. Keep deployment artifacts in the repo, but outside app logic
Recommended files:
Dockerfile
docker-compose.yml
.env.example
scripts/Minimal Dockerfile example:
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:
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 headsWhat to check:
- If imports are failing, search for modules importing the app object directly.
- If tests are brittle, search for
os.environreads 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
appinto 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.
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.
request flow diagram from router -> service -> repository -> database/external provider.
For final deployment validation, use SaaS Production Checklist.
Related guides
- Choosing a Tech Stack for a Small SaaS
- Environment Variables and Secrets Management
- Implement User Authentication (Login/Register)
- Stripe Subscription Setup (Step-by-Step)
- SaaS Production Checklist
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.