A production-ready FastAPI template following Domain-Driven Design (DDD) principles.
The core business logic lives in app/domains/ and is framework-agnostic.
Rules:
app/domains/contains models (ORM), repositories, schemas, and services- Domain code has ZERO dependencies on FastAPI, Prefect, or any framework
- FastAPI and Prefect are adapters that consume the domain
- All business rules and calculations belong in domain services
Domain Structure:
app/domains/
├── base/ # Base abstractions (shared across domains)
│ ├── authorization.py # AuthorizationContext, ScopeStrategy
│ ├── exceptions.py # Domain exceptions
│ ├── factory.py # Test factory base class
│ ├── filters.py # Filter parameters
│ ├── models.py # Base ORM model, mixins
│ ├── repository.py # Base repository with CRUD mixins
│ ├── schemas.py # Base Pydantic schemas
│ └── service.py # Base service with business logic mixins
└── users/ # User domain
├── authorization.py # User-specific scope strategies
├── exceptions.py # User domain exceptions
├── factory.py # User test factories
├── models.py # User, APIKey ORM models
├── repository.py # UserRepository, APIKeyRepository
├── schemas.py # User DTOs
└── service.py # UserService, APIKeyService
app/
├── api/ # FastAPI routes only (HTTP delivery layer)
│ ├── dependencies.py # FastAPI dependency injection
│ ├── exceptions.py # HTTP exceptions
│ ├── main.py # FastAPI app factory
│ ├── router.py # Route aggregation
│ └── routes/ # Route handlers
│ ├── health.py
│ ├── users.py
│ └── webhooks/
│ └── clerk.py
├── workers/ # Prefect flows/tasks only (async processing)
│ ├── flows/ # Prefect flow definitions
│ │ └── example.py
│ ├── tasks/ # Reusable Prefect tasks
│ │ └── example.py
│ └── main.py # Worker entry point
├── domains/ # Pure business logic (see above)
├── infrastructure/ # DB, settings, external APIs
│ ├── config.py # Application settings
│ ├── database.py # SQLAlchemy engine/session
│ └── security.py # Authentication (Clerk JWT, API keys)
- Create the domain folder:
app/domains/myentity/ - Add the files:
models.py- ORM modelsschemas.py- Pydantic DTOsrepository.py- Data access layerservice.py- Business logicexceptions.py- Domain exceptionsauthorization.py- Scope strategies (if needed)
- Register models in
app/models.pyandmigrations/env.py - Create API routes in
app/api/routes/ - (Optional) Create Prefect tasks/flows in
app/workers/
This template implements a two-layer defense-in-depth authorization system that separates permissions (what actions are allowed) from data scoping (what data is visible).
The authorization system operates at two independent layers:
- Service Layer (
_check_*_permissions): Validates whether a user can perform an action - Repository Layer (
_apply_user_scope): Filters which data the user can see
This separation ensures that a bug in one layer doesn't compromise the other, following security best practices.
API Route
↓
Service (for_user/for_system)
↓
├─→ Permission Checks (CAN the user DO this?)
│ ├─→ _check_general_permissions(action)
│ └─→ _check_instance_permissions(action, instance)
│
└─→ Repository Operations
↓
└─→ Scope Filtering (WHAT data can the user SEE?)
└─→ _apply_user_scope(query)
├─→ context is None? → return unfiltered query (system)
└─→ context exists? → scope_strategy.apply_scope(query, context)
1. AuthorizationContext (app/domains/base/authorization.py)
Abstract interface that provides user information:
class AuthorizationContext(ABC):
@property
@abstractmethod
def user_id(self) -> str: ...
@property
@abstractmethod
def user_email(self) -> str: ...
@property
@abstractmethod
def user_role(self) -> str: ...2. AuthorizationScopeStrategy (app/domains/base/authorization.py)
Defines how to filter queries based on user context. Each domain implements its own strategy:
class AuthorizationScopeStrategy(ABC, Generic[T]):
@abstractmethod
def apply_scope(self, query: Select, context: AuthorizationContext) -> Select:
"""Filter query results based on user context"""
...Example Implementation (app/domains/users/authorization.py):
class APIKeyScopeStrategy(AuthorizationScopeStrategy):
def apply_scope(self, query: Select, context: AuthorizationContext) -> Select:
# Only show API keys owned by the user
return query.where(self.model.user_id == context.user_id)3. Repository Layer (app/domains/base/repository.py)
The repository is the guardian that decides IF scope should be applied:
def _apply_user_scope(self, query: Select) -> Select:
"""Apply user scope using the repository's authorization context"""
if self.authorization_context is None:
return query # System operation: no filtering
return self.scope_strategy.apply_scope(query, self.authorization_context)Important: The repository handles the None check before calling apply_scope. The scope strategy never receives None - it only decides how to filter when a user context exists.
4. Service Layer (app/domains/base/service.py)
The service validates permissions before delegating to the repository:
def _check_general_permissions(self, action: str) -> bool:
"""Check if user can perform this action type"""
if self._is_system_operation():
return True # System operations bypass checks
# Override in subclasses for domain-specific logic
return True
def _check_instance_permissions(self, action: str, instance: ModelType) -> bool:
"""Check if user can perform this action on this specific instance"""
if self._is_system_operation():
return True
# Override in subclasses for domain-specific logic
return TrueServices provide two factory methods that make the authorization mode explicit:
# User operation: with authorization context
service = UserService.for_user(session, authorization_context)
# System operation: bypasses all checks (use with caution!)
service = UserService.for_system(session)When to use for_system():
- Background jobs / workers
- Webhook handlers (e.g., Clerk user sync)
- Admin scripts
- Internal system operations
When to use for_user():
- All API endpoints handling user requests
- Any operation triggered by an authenticated user
- Defense in Depth: Both service permissions and repository scoping must be bypassed for unauthorized access
- Query-Level Filtering: Data filtering happens at the SQL query level - unauthorized data never leaves the database
- Explicit System Mode:
for_system()makes privileged operations visible in code reviews - Type Safety: Type checker enforces that scope strategies receive non-None contexts
- Create Scope Strategy (
app/domains/myentity/authorization.py):
from app.domains.base.authorization import AuthorizationScopeStrategy, AuthorizationContext
from sqlalchemy import Select
class MyEntityScopeStrategy(AuthorizationScopeStrategy):
def __init__(self):
super().__init__(MyEntity)
def apply_scope(self, query: Select, context: AuthorizationContext) -> Select:
# Example: filter by owner
return query.where(self.model.owner_id == context.user_id)- Register in Repository (
app/domains/myentity/repository.py):
class MyEntityRepository(ListRepositoryMixin, ...):
def __init__(self, session: AsyncSession, authorization_context=None):
super().__init__(
session,
MyEntityScopeStrategy(), # Pass strategy here
MyEntity,
authorization_context
)- Override Permission Checks (
app/domains/myentity/service.py):
class MyEntityService(BaseService):
def _check_general_permissions(self, action: str) -> bool:
if self._is_system_operation():
return True
# Add your permission logic here
if action == "delete" and self.authorization_context.user_role != "admin":
raise PermissionDenied("Only admins can delete")
return True
def _check_instance_permissions(self, action: str, instance: MyEntity) -> bool:
if self._is_system_operation():
return True
# Add your instance-level checks here
if action == "update" and instance.owner_id != self.authorization_context.user_id:
raise PermissionDenied("Can only update own entities")
return True- Custom Repository Methods: Methods like
find_by_email()that bypass_apply_user_scopemust have their permissions checked in the service layer after fetching - System Operations: Always use
for_system()explicitly - never passNoneasauthorization_contextto constructors directly - Scope Strategy Contract:
apply_scopealways receives a non-NoneAuthorizationContext- the repository handles the None check before calling it
One repository, one multi-stage Dockerfile, multiple images:
# Build different images from the same Dockerfile
docker build --target api -t myapp:api .
docker build --target worker -t myapp:worker .
docker build --target migrations -t myapp:migrations .
# Run them separately
docker run -p 8000:80 myapp:api
docker run myapp:worker- Python 3.13+
- Docker & Docker Compose
- uv (Python package manager)
# Install dependencies
make install
# Start infrastructure (database)
docker-compose up -d dbapp
# Run migrations
make migrate
# Start the API server
make dev
# In another terminal, start worker (optional)
make workerUsing Prefect locally: For the worker or scripts (e.g. make init-prefect) to contact the Prefect server, export the auth (align with PREFECT_LOGIN_USER and PREFECT_LOGIN_PASSWORD in your .env):
export PREFECT_API_URL="http://0.0.0.0:4200/api"
export PREFECT_API_AUTH_STRING="admin:pass"
# or with .env credentials: export PREFECT_API_AUTH_STRING="${PREFECT_LOGIN_USER}:${PREFECT_LOGIN_PASSWORD}"Start Prefect services first: make docker-up-prefect, then run the worker with make worker.
# Build all images
make docker-build-all
# Start all services (db, migrations, api, worker)
make docker-up
# View logs
make docker-logs
# Stop everything
make docker-down| Command | Description |
|---|---|
make help |
Show available commands help |
make install |
Install dependencies with uv |
make dev |
Run FastAPI in development mode |
| Command | Description |
|---|---|
make test |
Run all tests |
make test-cov |
Run tests with coverage report (HTML + terminal) |
make lint |
Run linter (ruff) on app/ |
make format |
Format code with ruff |
make type-check |
Type check with ty |
| Command | Description |
|---|---|
make migrate |
Apply migrations (alembic upgrade head) |
make migrate-create MESSAGE="description" |
Create a new autogenerated migration |
make migrate-down |
Roll back the last migration |
make migrate-history |
Show migration history |
| Command | Description |
|---|---|
make create-user |
Create a new user (generate-user script) |
make init-prefect |
Initialize Prefect (blocks, work pool). Requires PREFECT_API_AUTH_STRING if the server is secured. |
| Command | Description |
|---|---|
make clean |
Remove caches (pycache, .pytest_cache, .ruff_cache, .coverage, htmlcov, etc.) |
make clean-docker |
Remove Docker images and volumes (app + Prefect) |
This project uses an automated versioning workflow with Release Candidates (RC).
Push to main → 0.1.1-rc.1 (automatic)
Push to main → 0.1.1-rc.2 (automatic)
Push to main → 0.1.1-rc.3 (automatic)
Manual release (patch) → 0.1.1 + tag v0.1.1
Push to main → 0.1.2-rc.1 (new cycle)
Manual release (minor) → 0.2.0 + tag v0.2.0
Push to main → 0.2.1-rc.1 (new cycle)
Manual release (major) → 1.0.0 + tag v1.0.0
- Go to GitHub Actions → Release
- Click Run workflow
- Choose release type:
| Type | Example | When to use |
|---|---|---|
patch |
1.2.3 → 1.2.4 | Bug fixes, small improvements |
minor |
1.2.3 → 1.3.0 | New features, backward compatible |
major |
1.2.3 → 2.0.0 | Breaking changes |
While commits don't affect versioning, we recommend using clear prefixes for readability:
feat: add user dashboard
fix: resolve login timeout issue
docs: update API documentation
refactor: simplify auth middleware
test: add integration tests for users
chore: update dependenciesSee .env.sample for all available configuration options.