An opinionated Web API template with Python, built around Domain-Driven Design (DDD), CQRS, and Clean Architecture.
| Category | Package | Role |
|---|---|---|
| Package Manager | uv | Dependency management |
| Web Framework | FastAPI | REST API |
| CLI | Typer | Command line interface |
| ORM | SQLAlchemy | Database access |
| Migration | Alembic | Schema migrations |
| Validation | Pydantic | Validation and serialization |
| DI | python-injection | Dependency injection |
| CQRS | python-cq | Command/Query Responsibility Segregation |
src/
├── core/ # Domain + Application layers (business logic)
│ └── {context}/
│ ├── domain/ # Domain layer (entities, value objects, aggregates)
│ └── ... # Application layer (commands, queries, ports, events)
├── services/ # Shared technical services (cross-cutting concerns)
└── infra/ # Infrastructure layer (concrete implementations)
The Domain layer contains pure business models: entities, value objects, and aggregates. It has no dependencies on external frameworks.
src/core/{context}/domain/
├── {aggregate}.py # Aggregates (entity that groups related objects and ensures they are always in a valid state together)
├── {entity}.py # Entities (objects with unique identity)
└── {value_object}.py # Value Objects (immutable, no identity)
| Package | Role | Justification |
|---|---|---|
pydantic |
Domain models | Native validation, immutability with frozen=True |
| Type | Path Pattern | Description |
|---|---|---|
| Aggregate | src/core/{context}/domain/{aggregate}.py |
Entity that groups related objects and ensures they are always in a valid state together |
| Entity | src/core/{context}/domain/{entity}.py |
Object with unique identity |
| Value Object | src/core/{context}/domain/{value_object}.py |
Immutable object without identity |
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, SecretStr, field_serializer
class UserSession(BaseModel):
id: UUID
user_id: UUID
created_at: datetime
last_use_at: datetime
secret: SecretStrThe Application layer contains use cases and orchestration logic: commands, queries, events, and ports. Everything in the bounded context folder except domain/.
src/core/{context}/
├── domain/ # Domain layer (see above)
├── commands/ # Commands and their Handlers (write operations)
├── queries/ # Queries and their Views (read operation definitions)
├── events/ # Domain Events
├── ports/ # Interfaces (Protocols) for dependency inversion
│ └── repo/ # Repository interfaces
└── shared/ # Shared code within the bounded context
| Package | Role | Justification |
|---|---|---|
python-cq |
CQRS | Command/Query separation, handler decoupling |
pydantic |
DTOs | Command/Event/Query/View validation |
| Type | Path Pattern | Description |
|---|---|---|
| Command | src/core/{context}/commands/{action}.py |
Action that modifies state |
| Query | src/core/{context}/queries/{query_name}.py |
Data reading (Query + View) |
| Event | src/core/{context}/events/{event}.py |
Domain event |
| Port (Repository) | src/core/{context}/ports/repo/{aggregate}.py |
Persistence interface |
| Port (Service) | src/core/{context}/ports/{service}.py |
External service interface |
from typing import NamedTuple
from uuid import UUID
from cq import command_handler
from pydantic import BaseModel, SecretStr, field_serializer
from src.core.auth.domain.session import UserSession
from src.core.auth.ports.repo.user_permission import UserPermissionRepository
from src.core.auth.ports.repo.user_session import UserSessionRepository
from src.core.auth.ports.token_generator import TokenGenerator
from src.core.auth.shared.access_token import encode_access_token
from src.core.auth.shared.session_token import encode_session_token
from src.services.datetime.abc import DateTimeService
from src.services.hasher.abc import Hasher
from src.services.jwt.abc import JWTService
from src.services.uuid.abc import UUIDGenerator
class OpenUserSessionCommand(BaseModel):
user_id: UUID
class UserTokens(BaseModel):
access_token: SecretStr
session_token: SecretStr
@field_serializer("access_token", "session_token", when_used="json")
def _dump_secret(self, value: SecretStr) -> str:
return value.get_secret_value()
@command_handler
class OpenUserSessionHandler(NamedTuple):
datetime: DateTimeService
hasher: Hasher
jwt: JWTService
repo: UserSessionRepository
token_generator: TokenGenerator
user_permission_repo: UserPermissionRepository
uuid: UUIDGenerator
async def handle(self, command: OpenUserSessionCommand) -> UserTokens:
user_id = command.user_id
session_secret = self.token_generator.generate(128)
session = self.new_session(user_id, session_secret)
await self.repo.save(session)
permissions = await self.user_permission_repo.get(user_id)
access_token = encode_access_token(self.jwt, user_id, permissions)
session_token = encode_session_token(session.id, session_secret)
return UserTokens(
access_token=SecretStr(access_token),
session_token=SecretStr(session_token),
)
def new_session(self, user_id: UUID, session_secret: str) -> UserSession:
now = self.datetime.utcnow()
return UserSession(
id=self.uuid.next(),
user_id=user_id,
created_at=now,
last_use_at=now,
secret=SecretStr(self.hasher.hash(session_secret)),
)from abc import abstractmethod
from typing import Protocol
from uuid import UUID
from src.core.auth.domain.session import UserSession
class UserSessionRepository(Protocol):
@abstractmethod
async def delete(self, session_id: UUID) -> None:
raise NotImplementedError
@abstractmethod
async def get(self, session_id: UUID) -> UserSession | None:
raise NotImplementedError
@abstractmethod
async def save(self, session: UserSession) -> None:
raise NotImplementedErrorfrom datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class GetPrivateUserProfileQuery(BaseModel):
user_id: UUID
class PrivateUserProfileView(BaseModel):
id: UUID
created_at: datetime
first_name: str
last_name: strThe Services layer defines abstract interfaces for common technical services used across the entire application. These are cross-cutting concerns that can be used by any layer.
src/services/{service_name}/
├── abc.py # Abstract interface (Protocol)
└── {impl}.py # Implementation
| Type | Path Pattern | Description |
|---|---|---|
| Service Interface | src/services/{service}/abc.py |
Abstract Protocol |
| Implementation | src/services/{service}/{impl}.py |
Concrete implementation |
from abc import abstractmethod
from typing import Protocol
class Hasher(Protocol):
@abstractmethod
def hash(self, value: str) -> str:
raise NotImplementedError
@abstractmethod
def verify(self, value: str, hashed_value: str) -> bool:
raise NotImplementedError
def needs_rehash(self, hashed_value: str) -> bool:
return Falsefrom argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from injection import injectable
from src.services.hasher.abc import Hasher
@injectable(on=Hasher)
class Argon2Hasher(Hasher):
def __init__(self) -> None:
self.__internal = PasswordHasher()
def hash(self, value: str) -> str:
return self.__internal.hash(value)
def verify(self, value: str, hashed_value: str) -> bool:
try:
return self.__internal.verify(hashed_value, value)
except (InvalidHashError, VerificationError):
return False
def needs_rehash(self, hashed_value: str) -> bool:
return self.__internal.check_needs_rehash(hashed_value)The Infrastructure layer contains all concrete implementations: API, database, external integrations, etc.
src/infra/
├── adapters/ # Port implementations (repositories, services)
│ └── {context}/
│ └── repo/ # SQLAlchemy repositories
├── api/
│ ├── builder.py # FastAPI configuration
│ ├── dependencies.py # FastAPI dependencies (auth, locale, etc.)
│ └── routes/ # Endpoints by domain
├── cli/
│ ├── builder.py # Typer configuration
│ └── apps/ # CLI commands
├── db/ # Database
│ ├── tables.py # SQLAlchemy table definitions
│ └── migrations/ # Alembic migrations
├── integrations/ # Third-party integrations (Stripe, etc.)
│ └── {provider}/
│ └── commands/ # Integration-specific commands
└── query_handlers/ # Query handlers (DB read operations)
| Package | Role | Justification |
|---|---|---|
fastapi |
API framework | Performance, native typing, auto OpenAPI |
uvicorn + uvloop |
ASGI server | Optimal async performance |
typer |
CLI | FastAPI-like API, autocompletion |
sqlalchemy[postgresql-asyncpg] |
Async ORM | Native async PostgreSQL support |
alembic |
Migrations | Standard for SQLAlchemy |
python-injection |
DI | Declarative dependency injection |
| Type | Path Pattern | Description |
|---|---|---|
| Adapter Repository | src/infra/adapters/{context}/repo/{aggregate}.py |
SQLAlchemy implementation of Port |
| Adapter Service | src/infra/adapters/{context}/{service}.py |
Service implementation |
| API Route | src/infra/api/routes/{route_set_name}.py |
FastAPI endpoints |
| DB Table | src/infra/db/tables.py |
SQLAlchemy models |
| Query Handler | src/infra/query_handlers/{context}/{query_name}.py |
Read handler |
| CLI App | src/infra/cli/apps/{app_name}.py |
Typer commands |
| Integration | src/infra/integrations/{provider}/ |
External provider specific code |
from dataclasses import dataclass
from uuid import UUID
from injection import injectable
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.auth.domain.session import UserSession
from src.core.auth.ports.repo.user_session import UserSessionRepository
from src.infra.db.tables import UserSessionTable
@injectable(on=UserSessionRepository)
@dataclass(frozen=True)
class SQLAUserSessionRepository(UserSessionRepository):
session: AsyncSession
async def delete(self, session_id: UUID) -> None:
stmt = delete(UserSessionTable).where(UserSessionTable.id == session_id)
await self.session.execute(stmt)
async def get(self, session_id: UUID) -> UserSession | None:
stmt = (
select("*")
.select_from(UserSessionTable)
.where(UserSessionTable.id == session_id)
)
row = (await self.session.execute(stmt)).mappings().one_or_none()
if row is None:
return None
return UserSession.model_validate(row)
async def save(self, session: UserSession) -> None:
table = self.to_table(session)
await self.session.merge(table)
@classmethod
def to_table(cls, session: UserSession) -> UserSessionTable:
return UserSessionTable(
id=session.id,
user_id=session.user_id,
created_at=session.created_at,
last_use_at=session.last_use_at,
secret=session.secret.get_secret_value(),
)from typing import Annotated
from uuid import UUID
from cq import QueryBus
from fastapi import APIRouter, Depends, HTTPException, status
from injection.ext.fastapi import Inject
from src.core.user_profile.queries.private import GetPrivateUserProfileQuery, PrivateUserProfileView
from src.infra.api.dependencies import get_claimant_id
router = APIRouter(prefix="/users", tags=["User"])
@router.get("/me")
async def get_me(
claimant_id: Annotated[UUID, Depends(get_claimant_id)],
query_bus: Inject[QueryBus[PrivateUserProfileView | None]],
) -> PrivateUserProfileView:
query = GetPrivateUserProfileQuery(user_id=claimant_id)
view = await query_bus.dispatch(query)
if view is None:
raise HTTPException(status_code=status.HTTP_428_PRECONDITION_REQUIRED)
return viewfrom typing import NamedTuple
from cq import query_handler
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.user_profile.queries.private import GetPrivateUserProfileQuery, PrivateUserProfileView
from src.infra.adapters.user_profile.repo.user_profile import UserStatus
from src.infra.db.tables import UserTable
@query_handler
class GetPrivateUserProfileHandler(NamedTuple):
session: AsyncSession
async def handle(
self,
query: GetPrivateUserProfileQuery,
) -> PrivateUserProfileView | None:
stmt = select(
UserTable.id,
UserTable.created_at,
UserTable.first_name,
UserTable.last_name,
).where(
UserTable.id == query.user_id,
UserTable.status == UserStatus.READY,
)
row = (await self.session.execute(stmt)).mappings().one_or_none()
if row is None:
return None
return PrivateUserProfileView.model_validate(row)The main.py file is the entry point for both the API and CLI. Routers and CLI apps must be manually registered here.
from injection import find_instance
from src.infra.api.builder import FastAPIBuilder
from src.infra.api.routes import auth, registration, user
from src.infra.cli.apps import db
from src.infra.cli.builder import TyperBuilder
if __name__ == "__main__":
cli = (
find_instance(TyperBuilder)
.include_apps(
db.app,
)
.build()
)
cli()
else:
app = (
find_instance(FastAPIBuilder)
.include_routers(
auth.router,
registration.router,
user.router,
)
.build()
)When adding new routes or CLI commands:
- New API router: Add
from src.infra.api.routes import {module}and include{module}.routerininclude_routers() - New CLI app: Add
from src.infra.cli.apps import {module}and include{module}.appininclude_apps()
Test implementations should be placed in tests/impl/. This folder contains deterministic implementations that replace production services during tests, making unit tests predictable and fast.
tests/
├── impl/ # Test implementations (deterministic replacements)
│ ├── services/ # Service test implementations
│ └── adapters/ # Adapter test implementations
├── core/ # Domain and application tests
├── infra/ # Infrastructure tests
└── services/ # Service tests
Production uses Argon2Hasher which is slow and non-deterministic. For tests, we use a simple SHA256Hasher:
from hashlib import sha256
from injection.testing import test_injectable
from src.services.hasher.abc import Hasher
@test_injectable(on=Hasher)
class SHA256Hasher(Hasher):
def hash(self, value: str) -> str:
b = value.encode()
h = sha256(b, usedforsecurity=False).hexdigest()
return f"sha256:{h}"
def verify(self, value: str, hashed_value: str) -> bool:
return hashed_value == self.hash(value)
def needs_rehash(self, hashed_value: str) -> bool:
return FalseThe @test_injectable(on=Hasher) decorator registers this implementation only during test execution, replacing the production Argon2Hasher.
# Installation
make install
# Development
make dev # Start uvicorn server in reload mode
# Database
make create-db # Create the database
make drop-db # Drop the database
make init-db # Drop + Create + Migrate
make migrate # Apply migrations
make makemigrations # Generate a new migration
# Code quality
make lint # Ruff format + check
make pytest # Run tests
make # lint + pytest- Domain depends on nothing - Domain (
src/core/{context}/domain/) must never import fromsrc/infra/ - Use Protocols - Define interfaces in
ports/for dependency inversion - Commands for writes - All state modifications go through a Command
- Queries for reads - Query Handlers are in infra because they access the DB directly
- Dependency injection - Use
@injectable(on=Protocol)for implementations - Register routers and apps - Always add new routers and CLI apps in
main.py
- No infra imports in core - Never
from src.infra import ...insrc/core/ - No SQLAlchemy in domain - Tables are only in
src/infra/db/tables.py - No business logic in routes - Routes only dispatch Commands/Queries