Architecture¶
The SDK enforces a strict router → controller → service → repository layering. Every Tempest project follows the same shape, so a developer dropped into a new repo finds the file they need on the first try.
The four layers¶
flowchart LR
subgraph HTTP
Router["📡 Router\n(api/routers/)"]
end
subgraph Coordination
Controller["🎼 Controller\n(controllers/)"]
end
subgraph Domain
Service["📐 Service\n(services/)"]
end
subgraph Data
Repository["🗄️ Repository\n(db/repositories/)"]
Model["🧱 SQLAlchemy Model\n(db/models/)"]
end
DB[(PostgreSQL / SQLite)]
Router -->|"Depends()"| Controller
Controller -->|orchestrates| Service
Service -->|domain rules| Repository
Repository -->|"SELECT/INSERT/UPDATE"| Model
Model -->|async SQLAlchemy| DB
What lives where¶
Layer responsibilities
| Layer | Owns | NEVER touches |
|---|---|---|
| Router | HTTP verbs, status codes, request/response schemas, Depends() |
DB, business logic |
| Controller | Coordination across multiple services, cross-cutting policy (audit log, outbox emit, downstream notify) | DB, request/response shape |
| Service | Domain rules (uniqueness, derived state, transactional flow) | HTTP, SQLAlchemy types |
| Repository | Raw async SQLAlchemy queries, CRUD + filter + pagination | Domain rules, HTTP |
The repository MUST be a BaseRepository[ModelType] subclass (or instance). The service MUST be a BaseService[RepositoryT, ResponseT] subclass. The controller MUST be a BaseController[ServiceT, ResponseT] subclass — even when every method is a pass-through, because the controller is the seam to add cross-service coordination later.
Mandatory project layout¶
<service>/
├── main.py # ONE-LINER: from src.server import run; run()
└── src/ (or app/)
├── __init__.py # re-exports run from src.server
├── server.py # programmatic uvicorn.run() + module-level FastAPI app
├── api/
│ ├── app.py # create_app() factory + middleware + exception handlers
│ ├── routers/ # HTTP endpoints, no business logic
│ ├── dependencies/ # PACKAGE (auth.py + controllers.py / services.py)
│ └── docs/ # OpenAPI customization
├── controllers/ # Orchestrate between services
├── services/ # Business logic layer
├── schemas/ # Pydantic v2 request/response DTOs
├── core/ # settings.py + constants + exceptions + logging
├── db/ (optional)
│ ├── models/ # SQLAlchemy ORM models
│ └── repositories/ # Data access layer
├── utils/ (optional) # Shared stateless helpers
├── queue/ (optional) # FastStream consumers/publishers
└── tasks/ (optional) # TaskIQ background tasks
Rules that are not negotiable
main.pyat the service root is a one-liner that importsrunfromsrc.server. Neversubprocess.run(["uvicorn", ...]).src/server.pyexposes both arun()function and the importableappinstance.api/dependencies/is always a package, never a flat file. Auth lives inauth.py; factory providers live incontrollers.py(orservices.pywhen there is no controller layer yet).- Routers receive controllers via
Depends, never constructed inline. - Meta endpoints (
/health,/tool-spec) live at the root prefix; business endpoints live under/api/<domain>.
Request lifecycle¶
sequenceDiagram
autonumber
participant C as Client
participant M as RequestIDMiddleware
participant L as RateLimitMiddleware
participant R as Router
participant Ctl as Controller
participant S as Service
participant Repo as Repository
participant DB as PostgreSQL
C->>M: HTTP request
M->>M: bind X-Request-ID into contextvar
M->>L: forward
L->>L: check sliding-window quota
L->>R: forward
R->>R: validate Pydantic schema
R->>Ctl: Depends(get_user_controller)
Ctl->>S: orchestrate
S->>Repo: filter / paginate / add
Repo->>DB: SELECT/INSERT/UPDATE
DB-->>Repo: rows
Repo-->>S: ORM instances
S-->>Ctl: ResponseSchema
Ctl-->>R: ResponseSchema
R->>M: serialize Pydantic → JSON
M-->>C: HTTP response + X-Request-ID header
Every step has a clear owner — the router never talks to SQLAlchemy, the repository never raises HTTP exceptions (it raises the not_found_exception configured on __init__, and the exception handler turns it into the JSON envelope).
Exception envelope¶
The SDK ships AppException + register_exception_handlers so every error in your service serializes to the same JSON shape:
{
"detail": "Usuário não encontrado",
"code": "USER_NOT_FOUND",
"details": {"user_id": "01923..."}
}
The frontend branches on code (stable, machine-readable), never on the (potentially translated) detail.
Where to go next¶
| You want to… | Read |
|---|---|
| Build a feature step by step | Tutorial » |
| Wire a specific helper | Recipes » |
| Look up a class signature | Reference » |
| Upgrade from an older version | Migration guide » |
Controllers & services layering¶
BaseService[RepositoryT, ResponseT] and BaseController[ServiceT, ResponseT] are generic skeletons matching the SDK layering (router → controller → service → repository). They expose pass-through CRUD methods so simple endpoints can subclass them without overriding anything; you override only methods that need orchestration.
What you inherit by subclassing BaseService[RepositoryT, ResponseT]:
| Method | Returns | Notes |
|---|---|---|
get_by_id(id) |
ResponseT |
Awaits repository.get_by_id + repository.map_to_response. Raises repository.not_found_exception on miss. |
get_or_none(filters) |
ResponseT \| None |
Same shape, returns None instead of raising. |
list(filters=None, order_by=None, ascending=True) |
list[ResponseT] |
Returns [] on empty match (never raises). |
paginate(filters=None, order_by=None, page=1, page_size=20, ascending=True) |
dict with mapped items + total/page/page_size/pages. |
Offset pagination via repository.paginate. |
count(filters=None) |
int |
Pass-through to repository.count. |
exists(filters) |
bool |
Pass-through to repository.exists. |
delete(id) |
None |
Hard delete via repository.delete. |
map_to_response is await-ed when it returns a coroutine, so async mappers work transparently — no method override needed.
What you inherit by subclassing BaseController[ServiceT, ResponseT]:
| Method | Forwards to | Notes |
|---|---|---|
get_by_id(id) |
service.get_by_id |
Same return type as the service. |
list(filters, order_by, ascending) |
service.list |
Same. |
paginate(filters, order_by, page, page_size, ascending) |
service.paginate |
Same. |
count(filters) |
service.count |
Same. |
delete(id) |
service.delete |
Same. |
When a use case needs domain rules, override the inherited method in the service. When a use case needs to coordinate more than one service, override the inherited method (or add a new one) in the controller. The router never grows — it only depends on the controller.
# src/services/user_service.py
from uuid import UUID
from tempest_fastapi_sdk import BaseService
from src.db.repositories import UserRepository
from src.schemas.user import UserCreate, UserResponse, UserUpdate
from src.utils.security import password_utils
class UserService(BaseService[UserRepository, UserResponse]):
"""Business logic for the user feature."""
async def signup(self, data: UserCreate) -> UserResponse:
# Business logic — hash the password, then delegate to the repo.
instance = self.repository.map_to_model(
{
"name": data.name,
"email": data.email,
"password_hash": password_utils.hash(data.password),
},
)
created = await self.repository.add(instance)
return self.repository.map_to_response(created)
# src/controllers/user_controller.py
from tempest_fastapi_sdk import BaseController
from src.schemas.user import UserCreate, UserResponse
from src.services.user_service import UserService
class UserController(BaseController[UserService, UserResponse]):
"""Thin orchestration over UserService."""
async def signup(self, data: UserCreate) -> UserResponse:
# Pass-through today; the controller is the seam to add
# cross-service coordination later (audit log, outbox event,
# downstream notification, etc.) without touching the router.
return await self.service.signup(data)
# src/api/dependencies/controllers.py
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.app import db
from src.controllers.user_controller import UserController
from src.db.repositories import UserRepository
from src.services.user_service import UserService
def get_user_controller(
session: AsyncSession = Depends(db.session_dependency),
) -> UserController:
# UserRepository is a subclass of BaseRepository[UserModel] whose
# __init__ injects `model=UserModel` via super().__init__(session, model=UserModel).
# See the tutorial for the full skeleton:
# https://mauriciobenjamin700.github.io/tempest-fastapi-sdk/en/tutorial/#6-repository
return UserController(UserService(UserRepository(session)))
# src/api/routers/users.py
from fastapi import APIRouter, Depends, status
from src.api.dependencies.controllers import get_user_controller
from src.controllers.user_controller import UserController
from src.schemas.user import UserCreate, UserResponse
router = APIRouter(prefix="/users", tags=["users"])
@router.post(
"/",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_user(
data: UserCreate,
controller: UserController = Depends(get_user_controller),
) -> UserResponse:
return await controller.signup(data)
Keep controllers present even when they only pass through — the import graph stays uniform across services, so adding cross-cutting policy later doesn't change the router signature.