Guidelines and conventions for contributing to the SyncDesk project.
- Project Architecture
- Domain Modules
- Layered Architecture & Separation of Concerns
- Naming Conventions
- Entities vs Models
- Repositories
- Services
- Routers
- Schemas & DTOs
- Dependency Injection
- Exceptions
- Database Migrations
- Logging
- Metrics
- Testing
- Code Quality
- Import & Export Conventions
- Commit & PR Guidelines
The codebase follows a domain-driven modular structure. Business features live under app/domains/, while cross-cutting infrastructure lives under app/core/ and app/db/.
app/
├── core/ # Infrastructure: config, logging, security, middleware, metrics
├── db/ # Database engine, session, shared exceptions
├── domains/ # Feature modules (auth, health, …)
├── schemas/ # Shared response envelope schemas
├── api/ # Versioned API router aggregation
└── seed/ # Database seed scripts
Each layer has a single responsibility. Never import upward (e.g., core/ must not import from domains/).
Each business feature is a self-contained module under app/domains/. A full-featured domain follows this structure:
domains/{feature}/
├── __init__.py # Exports routers only
├── dependencies.py # FastAPI DI wiring
├── entities.py # Domain dataclasses (no ORM)
├── enums.py # Enums for the domain
├── exceptions.py # Domain-specific exceptions
├── models.py # SQLAlchemy ORM models
├── types.py # Custom types (NewType aliases)
├── README.md # Domain documentation
├── repositories/
│ └── {resource}_repository.py
├── routers/
│ └── {resource}_router.py
├── schemas/
│ ├── __init__.py # Re-exports all schemas
│ ├── api_schemas.py # API request/response shapes
│ └── {resource}_schemas.py
└── services/
└── {resource}_service.py
Simple domains (like health) can flatten this — a single routers.py is fine when there are no repositories or complex logic.
When creating a new domain:
- Create the folder under
app/domains/. - Define models, entities, enums, and exceptions.
- Write the repository, service, and router layers.
- Wire dependencies in
dependencies.py. - Export routers from
__init__.py. - Register routers in
app/api/api_router.py. - Create an Alembic migration for any new tables.
- Add a
README.mddocumenting the domain.
The data flow follows strict layers:
Router → Service → Repository → Database
| Layer | Responsibility | Can depend on |
|---|---|---|
| Router | HTTP handling, request/response shapes, status codes | Service, Schemas, ResponseFactory |
| Service | Business logic, orchestration, validation | Repository, Entities |
| Repository | Database access, ORM queries, entity mapping | Models, Entities, DB session |
| Entity | Domain objects, business rules (no I/O) | Nothing (pure domain) |
| Model | ORM table definitions (no business logic) | Base |
Rules:
- Routers never access the database directly — always go through a service.
- Services never import SQLAlchemy or ORM models — they work with entities and DTOs.
- Repositories map between ORM models and domain entities. They own the
_to_entity()conversion. - Entities are pure Python dataclasses. They must not depend on FastAPI, SQLAlchemy, or any framework.
- Models define the database schema. They have no business logic.
| Type | Pattern | Example |
|---|---|---|
| Repository | {resource}_repository.py |
user_repository.py |
| Service | {resource}_service.py |
user_service.py |
| Router | {resource}_router.py |
user_router.py |
| Schemas | {resource}_schemas.py |
user_schemas.py |
| API schemas | api_schemas.py |
api_schemas.py |
| Domain shared | Singular name | models.py, entities.py, enums.py |
| Type | Pattern | Example |
|---|---|---|
| Entity | {Entity} |
User, Session, UserWithRoles |
| Model | {Entity} |
User, Role, Permission |
| Repository | {Entity}Repository |
UserRepository |
| Service | {Entity}Service |
UserService, AuthService |
| Create DTO | Create{Entity}DTO |
CreateUserDTO |
| Update DTO | Update{Entity}DTO |
UpdateUserDTO |
| Replace DTO | Replace{Entity}DTO |
ReplaceUserDTO |
| API request | {Entity}{Action}Request |
UserLoginRequest |
| API response | {Action}Response |
LoginResponse |
| Exception | {Description}Error |
UserNotFoundError |
| DI alias | {Entity}{Type}Dep |
UserServiceDep, PgSessionDep |
- snake_case for all functions and variables.
- Repository methods:
create(),get_all(),get_by_id(),get_by_{field}(),update(),delete(),get_with_{relation}(),add_{relation}(). - DI factory functions:
get_{resource}_{type}()— e.g.,get_user_repository(),get_user_service(). - Router handlers: descriptive verb + noun — e.g.,
create_user(),get_users(),update_user(). - Private helpers: prefix with
_— e.g.,_to_entity(),_create_tables().
Router instances are lowercase snake_case: user_router, auth_router, metrics_router.
The project separates domain entities from ORM models:
- Python
@dataclassclasses — no SQLAlchemy imports. - Contain business logic methods (e.g.,
is_expired(),can_login(),matches_device_fingerprint()). - Validation helpers (e.g.,
validate_email(),validate_username()). - Serialization helpers (e.g.,
to_response_dict()). - Can compose other entities (e.g.,
UserWithRoleshas a list ofRole).
- SQLAlchemy 2.0 classes using
Mapped[]types and inheriting fromBase. - Define the database schema: table name, columns, foreign keys, indexes, relationships.
- No business logic — they are pure schema definitions.
This decouples domain logic from the persistence layer. Services and business rules operate on entities, making them testable without a database.
Repositories encapsulate all database access for a given resource.
class UserRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
@require_dto(CreateUserDTO)
async def create(self, dto: CreateUserDTO) -> User:
# Insert + commit + return entity
...
async def get_by_id(self, user_id: UUID) -> User | None:
# Query + map to entity
...
def _to_entity(self, row: Row[Any]) -> User:
# Map ORM row to entity dataclass
...- Accept DTOs or primitive types as input, return entities (never ORM models).
- Use
@require_dto()to guard mutation methods. - Catch
IntegrityErrorand raiseResourceAlreadyExistsErrorfor unique constraint violations. - Call
await self.db.commit()after write operations. - Private
_to_entity()method handles the mapping from database rows to domain entities.
Services contain business logic and orchestrate repositories.
class UserService:
def __init__(self, user_repository: UserRepository) -> None:
self.user_repository = user_repository
async def create(self, dto: CreateUserDTO) -> User:
return await self.user_repository.create(dto)
async def get_by_id(self, user_id: UUID) -> User:
user = await self.user_repository.get_by_id(user_id)
if not user:
raise UserNotFoundError()
return user- Receive repositories (not sessions) via constructor.
- Work with entities and DTOs — never import SQLAlchemy or ORM models.
- Raise domain-specific exceptions when business rules are violated.
- Complex services (like
AuthService) can depend on multiple other services. - Keep methods focused — one action per method.
Routers handle HTTP concerns: parsing requests, calling services, formatting responses.
router = APIRouter()
@router.post("/", status_code=status.HTTP_201_CREATED, responses=create_responses)
async def create_user(
body: RegisterUserRequest,
service: UserServiceDep,
response: ResponseFactoryDep,
_auth: CurrentUserSessionDep,
_perm: bool = require_permission("user:create"),
) -> JSONResponse:
try:
user = await service.create(CreateUserDTO(**body.model_dump()))
return response.success(data=user.to_response_dict(), status_code=status.HTTP_201_CREATED)
except ResourceAlreadyExistsError:
raise AppHTTPException(status_code=409, title="Conflict", detail="Email already exists")- Always use
ResponseFactoryto build responses — never return raw dicts. - Convert domain exceptions to
AppHTTPExceptionwith appropriate HTTP status codes. - Use
Annotated[..., Depends()]type aliases (e.g.,UserServiceDep) for clean signatures. - Use
require_permission("resource:action")for authorization. - Prefix unused auth/permission dependencies with
_(e.g.,_auth,_perm). - Document responses with a
responsesdict for OpenAPI. - Use appropriate status codes:
201for creation,200for success,204for no content.
- Inherit from
BaseDTO(which setsextra = "forbid"— rejects unexpected fields). CreateDTO: all required fields for creation.UpdateDTO: all fields optional (partial update).ReplaceDTO: extendsCreateDTO(full replacement).- Use Pydantic validators (
@field_validator,@model_validator) for input validation.
- Inherit from
BaseModel(notBaseDTO). - Define the shape of HTTP request bodies and response data.
{Entity}{Action}Requestfor input,{Action}Responsefor output.
All schemas for a domain are re-exported from schemas/__init__.py with __all__.
All DI wiring for a domain lives in dependencies.py.
# Repository → needs DB session
def get_user_repository(db: PgSessionDep) -> UserRepository:
return UserRepository(db)
# Service → needs repository
def get_user_service(
repo: Annotated[UserRepository, Depends(get_user_repository)]
) -> UserService:
return UserService(repo)
# Type alias for routers
UserServiceDep = Annotated[UserService, Depends(get_user_service)]- One factory function per dependency:
get_{resource}_{type}(). - Export
Annotatedtype aliases ({Resource}{Type}Dep) for use in routers. - Async dependencies for anything requiring I/O (e.g., auth validation).
- Permission checking uses higher-order functions (
require_permission(name)).
- Inherit from
Exception. - Include a default error message with optional context.
- Naming:
{Description}Error(e.g.,UserNotFoundError,InvalidCredentialsError).
ResourceAlreadyExistsError(resource_name, identifier)— for unique constraint violations.ResourceNotFoundError(resource_name, identifier)— for missing resources.
In routers, catch domain exceptions and raise AppHTTPException:
except UserNotFoundError:
raise AppHTTPException(status_code=404, title="Not Found", detail="User not found")Never let domain exceptions leak to the client unhandled. The global exception handler catches anything unhandled and returns a generic 500 response, but explicit handling is preferred.
Migrations are managed with Alembic. See alembic/README for full CLI reference.
- Modify the SQLAlchemy model in the domain's
models.py. - Generate a migration:
make makemigration m="describe the change". - Review the generated file in
alembic/versions/— autogenerate can miss column renames, enum changes, custom functions, and triggers. - Apply:
make migrate. - Test both directions: verify
upgradeanddowngradework.
- Always implement
downgrade()— every migration must be reversible. - One logical change per migration — don't combine unrelated schema changes.
- Never edit a migration that has been applied to shared environments. Create a new one to fix issues.
- Manual SQL is required for triggers, functions, extensions, and enum types. Autogenerate won't handle these.
- Update the seed (
app/seed/seed.py) if the migration adds tables that need default data.
When ENVIRONMENT=development, the app auto-creates and drops all tables on startup via init_postgres_db(). This bypasses Alembic entirely. For anything beyond throwaway local dev, always use migrations.
Use the project's AsyncLogger — never use print() or raw logging.
from app.core.logger import get_logger
logger = get_logger()
logger.info("User created", extra={"user_id": str(user.id)})
logger.error("Payment failed", extra={"order_id": order_id})- Use structured data via
extra={}— don't concatenate variables into the message string for context. - Use appropriate levels:
debugfor development trace,infofor business events,warningfor recoverable issues,errorfor failures. - Never log sensitive data: passwords, tokens, full credit card numbers, or personal data.
- Logs are JSON — keep messages short and machine-parseable.
- The logger is a singleton (
@lru_cache). Importget_loggerwhere needed.
The project uses Prometheus metrics via the app.core.metrics module.
HTTP request metrics (count, latency, errors) are collected automatically by the metrics middleware. No action is needed for new endpoints.
-
Register the metric in
app/core/metrics/global_metrics.py:order_count = prometheus.register_counter( "app_orders_total", "Total orders placed", ["status"] )
-
Use it in your service or repository:
from app.core.metrics.global_metrics import order_count order_count.labels(status="completed").inc()
Wrap any background async function with @track_background_job("job_name") to automatically track run count, failures, and duration.
- Prefix all metrics with
app_for application metrics. - Use
_totalsuffix for counters,_secondsfor histograms measuring time,_percentagefor gauges measuring percentages.
tests/
├── conftest.py # Global fixtures (DB, client, app)
├── app/
│ ├── e2e/
│ │ ├── conftest.py # E2E-specific fixtures
│ │ └── domains/ # E2E tests per domain
│ └── integration/
│ └── domains/ # Integration tests per domain
- Tests run with
ENVIRONMENT=test, targeting a separate_testdatabase. - Use the
clientfixture for HTTP-level tests (AsyncClient with ASGI transport). - Use the
db_sessionfixture for direct database operations in tests. - Each test is isolated — the session is rolled back after every test.
- All test functions are
async defand usepytest-asyncio. - Name test files
test_{feature}.pyand test functionstest_{what_it_tests}.
make test # Full suite with coverage
make test-e2e # E2E tests only- E2E tests: Hit real endpoints via
client, assert on HTTP status + response body. - Integration tests: Test service/repository logic with a real database (via
db_session). - Test both success paths and error paths (invalid input, missing resources, permission denied).
- Override dependencies with
app.dependency_overrides[original] = replacementwhen needed.
Install once:
poetry run pre-commit installHooks run automatically on every commit: Ruff (lint + format), mypy, Bandit.
make lint # Ruff + Bandit
make format # Auto-format
make typecheck # mypy strict mode
make pre-commit # All of the above + tests- Ruff handles linting and formatting. Line length is 100. Target is Python 3.12.
- mypy runs in strict mode. All functions must have type annotations.
- Bandit scans for security issues. Don't suppress warnings without justification.
- Fix all lint/type errors before pushing — CI will enforce the same checks.
Export only routers. Everything else is accessed via direct imports within the domain.
# app/domains/auth/__init__.py
from .routers.auth_router import auth_router
from .routers.user_router import user_router
__all__ = ["auth_router", "user_router"]Re-export all DTOs and schemas for convenient single-point imports.
Exports the public API surface that other modules depend on.
- Use explicit
__all__lists in every__init__.py. - Prefer relative imports within a module (
from .models import User). - Use absolute imports when crossing module boundaries (
from app.db import PgSessionDep).
- Write clear, concise commit messages describing what changed and why.
- Keep commits focused — one logical change per commit.
- Run
make pre-commitbefore pushing. - PRs should include tests for new features and bug fixes.
- Update the domain's
README.mdif the change affects behavior or API contracts. - If adding a new domain, include documentation from the start.