SaaS Folder Structure Best Practices
The essential playbook for implementing saas folder structure best practices in your SaaS.
A good SaaS folder structure reduces import chaos, keeps features isolated, and makes deployment, testing, and onboarding easier. For indie developers and small teams, the goal is not a perfect enterprise layout. The goal is a structure that supports fast iteration now and clean scaling later.
Quick Fix / Quick Setup
Use this as a practical default for a Flask or FastAPI SaaS app:
project/
├── app/
│ ├── api/
│ │ ├── dependencies/
│ │ ├── middleware/
│ │ └── routes/
│ ├── core/
│ │ ├── config.py
│ │ ├── security.py
│ │ └── logging.py
│ ├── db/
│ │ ├── models/
│ │ ├── migrations/
│ │ └── session.py
│ ├── services/
│ ├── repositories/
│ ├── schemas/
│ ├── tasks/
│ ├── templates/
│ ├── static/
│ └── main.py
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
├── scripts/
├── docs/
├── .env.example
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── README.mdUse this structure as the default baseline. Keep framework boot files small, move business logic into services/, and switch to domain-based modules once the app grows past a few routes.
Minimal backend boot file example:
# app/main.py
from fastapi import FastAPI
from app.api.routes import health, users, billing
app = FastAPI()
app.include_router(health.router)
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(billing.router, prefix="/billing", tags=["billing"])Recommended config entrypoint:
# app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "my-saas"
environment: str = "development"
database_url: str
secret_key: str
stripe_secret_key: str | None = None
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()What’s happening
Most SaaS codebases start flat: routes, models, templates, utilities, and config files all mixed together.
That works for an MVP, but it breaks down when auth, billing, background jobs, admin tools, and integrations are added.
A strong folder structure separates:
- transport layer
- business logic
- data access
- config
- operational code
Main risks:
- overengineering too early
- adding too many layers before real complexity exists
- letting route handlers become the place where everything happens
A good structure should make it obvious:
- where new code belongs
- where config is loaded
- where database access happens
- where background jobs live
- how to test a feature without starting the whole app
| Aspect | a flat MVP structure | a layered production-ready structure |
|---|---|---|
| — | — | — |
side-by-side comparison of a flat MVP structure versus a layered production-ready structure.
Step-by-step implementation
1. Create one application root
Put runtime app code under a single app/ directory.
Why:
- keeps imports predictable
- avoids root-level file sprawl
- makes packaging and test discovery easier
Example:
project/
├── app/
├── tests/
├── scripts/
└── README.md2. Centralize core infrastructure
Put shared cross-cutting code in app/core/.
Use it for:
- config
- logging
- security helpers
- startup settings
- feature flags
- constants that are truly global
Example:
app/core/
├── config.py
├── logging.py
└── security.pyDo not scatter config loading across route files or service modules.
Related setup: Environment Variables and Secrets Management
3. Separate HTTP transport from business logic
Put endpoint handlers in app/api/routes/.
These files should mostly do:
- request parsing
- auth/session dependency use
- schema validation
- calling services
- returning responses
They should not contain:
- complex billing logic
- large DB transaction flows
- email sending logic
- retry logic
- multi-step business state changes
Example:
# app/api/routes/users.py
from fastapi import APIRouter, Depends
from app.schemas.user import CreateUserRequest, UserResponse
from app.services.user_service import create_user
router = APIRouter()
@router.post("/", response_model=UserResponse)
def create_user_endpoint(payload: CreateUserRequest):
user = create_user(payload)
return user4. Add request-scoped dependencies and middleware
Use app/api/dependencies/ for:
- current user resolution
- DB session injection
- tenant resolution
- permission helpers
Use app/api/middleware/ for:
- request IDs
- CORS
- structured logging hooks
- rate limiting wrappers
- audit headers
Example:
app/api/
├── dependencies/
│ ├── auth.py
│ └── db.py
├── middleware/
│ └── request_id.py
└── routes/5. Isolate database concerns
Keep DB code under app/db/.
Suggested layout:
app/db/
├── models/
├── migrations/
└── session.pyUse session.py for engine/session creation:
# app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)Keep ORM models out of route files.
Keep migration history in one dedicated place and apply it consistently in development, staging, and production.
Related setup: Development vs Production Environments
6. Use schemas for API contracts
Put request/response validation objects in app/schemas/.
Examples:
- Pydantic models
- Marshmallow schemas
- serializer objects
Example:
# app/schemas/user.py
from pydantic import BaseModel, EmailStr
class CreateUserRequest(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: str
email: EmailStrBenefits:
- cleaner route handlers
- explicit contracts
- easier test assertions
- safer refactors
7. Move business logic into services
Use app/services/ for workflows such as:
- signup flow
- subscription activation
- invoice generation
- email verification
- report generation
- webhook processing
Example:
# app/services/user_service.py
from app.db.models.user import User
def create_user(payload):
user = User(email=payload.email)
# hashing, validation, persistence, event trigger
return userIf a service grows too large, split by domain:
app/services/
├── auth_service.py
├── billing_service.py
└── user_service.pyor later:
app/modules/
├── auth/
├── billing/
└── users/8. Add repositories only when needed
A repositories/ layer is optional.
Use it when:
- ORM queries are duplicated across services
- query logic becomes hard to test
- you want consistent access patterns for reads/writes
Skip it if the app is still small.
Bad pattern:
- adding repository abstractions before there is enough complexity to justify them
9. Create a dedicated tasks layer
Put async or scheduled jobs in app/tasks/.
Use it for:
- sending emails
- webhook retries
- scheduled cleanup
- report generation
- background sync jobs
Example:
app/tasks/
├── emails.py
├── billing_retries.py
└── cleanup.pyIf you use Celery, RQ, or FastAPI background tasks, keep job code here rather than mixing it into request handlers.
10. Keep frontend assets separate from backend logic
Only use templates/ and static/ if the backend serves HTML or assets directly.
app/
├── templates/
└── static/If you have a separate frontend, keep frontend code out of the Python app tree or use a clearly separate workspace directory.
11. Mirror the app structure in tests
Use:
tests/
├── unit/
├── integration/
└── e2e/Guidelines:
unit/: service functions, pure helpers, validationintegration/: DB, queues, external adapters, API slicese2e/: full user flows
Example:
tests/
├── unit/test_user_service.py
├── integration/test_signup_api.py
└── e2e/test_checkout_flow.pyUseful command:
pytest --collect-only12. Put operational scripts outside runtime code
Create a scripts/ directory for:
- data backfills
- admin actions
- seeding
- maintenance jobs
- repair scripts
- migration helpers
Example:
scripts/
├── seed_dev_data.py
├── backfill_customer_ids.py
└── reprocess_failed_webhooks.pyDo not leave these files at the repo root.
13. Standardize naming before the app gets bigger
Choose conventions early:
- singular vs plural directories
- import style
- module naming
- test naming
- service naming
- migration workflow
Example rules:
models/,schemas/,routes/- one config source:
app/core/config.py - one app entrypoint:
app/main.py - no wildcard imports
- no generic
utils.pydumping ground
Useful check:
grep -R "import \*" appCommon causes
Folder structures usually become messy for a few repeatable reasons:
- starting with a flat structure and never refactoring as features increase
- putting business logic directly inside Flask or FastAPI route handlers
- mixing app code, deployment files, migration files, and admin scripts without boundaries
- using generic files like
utils.pyorhelpers.pyas catch-all storage - creating too many layers too early, which slows development and makes navigation harder
- not defining a consistent place for config, database access, and background jobs
- letting tests drift into random locations so coverage becomes hard to maintain
Debugging tips
Use these commands to inspect structure drift and misplaced logic.
Inspect the repository tree
find . -maxdepth 3 -type f | sorttree -L 3find app -type f | sortFind scattered config loading
grep -R "os.getenv\|dotenv\|BaseSettings" appIf many files load env vars directly, config is too distributed.
Find route definitions
grep -R "@app\.|APIRouter\|Blueprint" appIf route files are huge or mixed with unrelated logic, split transport from services.
Find database session creation
grep -R "create_engine\|Session\|sessionmaker" appIf DB setup appears in multiple files, centralize it in app/db/session.py.
Find background task code
grep -R "Celery\|rq\|Redis\|BackgroundTasks" appIf tasks are scattered across routes and services, move them into app/tasks/.
Find unsafe imports
grep -R "import \*" appWildcard imports make module boundaries harder to trace.
Validate test discovery
pytest --collect-onlyIf tests are not discoverable, naming or folder structure is inconsistent.
Catch import and syntax issues
python -m compileall appChecklist
- ✓ There is one clear application root directory.
- ✓ Config, security, and logging are centralized.
- ✓ Business logic is outside route handlers.
- ✓ Database models and migrations are separated from request code.
- ✓ Background jobs live in a dedicated folder.
- ✓ Tests mirror application structure.
- ✓ Operational scripts are stored outside runtime app code.
- ✓ Secrets are not committed;
.env.exampleexists. - ✓ Root directory is not cluttered with ad hoc scripts and backup files.
Use this with: SaaS Production Checklist
Related guides
- Structuring a Flask/FastAPI SaaS Project
- Choosing a Tech Stack for a Small SaaS
- Environment Variables and Secrets Management
- Development vs Production Environments
- SaaS Production Checklist
FAQ
What is the best folder structure for a small SaaS?
Use a simple layered structure with app/, core/, api/, db/, services/, tasks/, and tests/. Add feature modules later when the codebase grows.
Should I split code by feature from day one?
Usually no. For an MVP, a clean layered structure is faster. Split by feature once auth, billing, notifications, and teams become distinct domains.
Where should environment config live?
Put it in a single config module such as app/core/config.py and load values from environment variables, not scattered constants.
Where should deployment files go?
Keep Dockerfile, compose files, CI config, and infrastructure files at the repo root or in a dedicated deploy/ or infra/ folder, separate from runtime app code.
How do I know my current structure is failing?
If new features require editing many unrelated files, imports are hard to trace, route files are very large, or duplicate logic appears across modules, the structure needs cleanup.
Final takeaway
The best folder structure is the one that keeps code discoverable, reduces coupling, and supports safe iteration.
For most small SaaS apps, start with:
app/core/api/db/services/tasks/tests/
Avoid both extremes:
- one giant flat folder
- enterprise-grade architecture before you need it
If your current project is still small, use a layered structure now. Move to domain-based modules only when real feature boundaries appear.