Arquitetura¶
O SDK impõe um fatiamento estrito router → controller → service → repository. Todo projeto Tempest segue o mesmo formato, então um desenvolvedor jogado em um repositório novo encontra o arquivo que precisa logo de primeira.
As quatro camadas¶
flowchart LR
subgraph HTTP
Router["📡 Router\n(api/routers/)"]
end
subgraph Coordenação
Controller["🎼 Controller\n(controllers/)"]
end
subgraph Domínio
Service["📐 Service\n(services/)"]
end
subgraph Dados
Repository["🗄️ Repository\n(db/repositories/)"]
Model["🧱 Modelo SQLAlchemy\n(db/models/)"]
end
subgraph Interface
UI["🖼️ UI\n(ui/)"]
end
DB[(PostgreSQL / SQLite)]
Router -->|"Depends()"| Controller
Router -->|"dados prontos"| UI
Controller -->|orquestra| Service
Service -->|regras de domínio| Repository
Repository -->|"SELECT/INSERT/UPDATE"| Model
Model -->|SQLAlchemy async| DB
O que vive onde¶
Responsabilidades de cada camada
| Camada | Responsável por | NUNCA toca |
|---|---|---|
| Router | Verbos HTTP, status codes, schemas de request/response, Depends() |
DB, lógica de negócio |
| Controller | Coordenação entre múltiplos services, política transversal (log de auditoria, emissão de outbox, notificação downstream) | DB, formato de request/response |
| Service | Regras de domínio (unicidade, estado derivado, fluxo transacional) | HTTP, tipos do SQLAlchemy |
| Repository | Queries SQLAlchemy async cruas, CRUD + filtro + paginação | Regras de domínio, HTTP |
| UI (quando o serviço entrega HTML) | Páginas, componentes, formulários e folha de estilo — como isso aparece | DB, controllers, services, HTTP |
O repository DEVE ser uma subclasse (ou instância) de BaseRepository[ModelType]. O service DEVE ser uma subclasse de BaseService[RepositoryT, ResponseT]. O controller DEVE ser uma subclasse de BaseController[ServiceT, ResponseT] — mesmo quando todo método é um pass-through, porque o controller é a costura para adicionar coordenação entre services mais tarde — ou, quando orquestra vários services sem CRUD de um recurso só, de Controller (veja Controller que orquestra).
Layout obrigatório do projeto¶
<service>/
├── main.py # ONE-LINER: from src.server import run; run()
└── src/ (ou app/)
├── __init__.py # re-exporta run de src.server
├── server.py # uvicorn.run() programático + app FastAPI no nível do módulo
├── api/
│ ├── app.py # factory create_app() — middleware + handlers + wiring (magro)
│ ├── routers/ # endpoints HTTP, sem lógica de negócio
│ ├── dependencies/ # PACOTE (auth.py + resources.py + controllers.py / services.py)
│ └── docs/ # customização do OpenAPI
├── controllers/ # orquestra entre services
├── services/ # camada de lógica de negócio
├── schemas/ # DTOs de request/response Pydantic v2
├── core/ # settings.py + constants + exceptions + logging
├── db/ (opcional)
│ ├── configs/names.py # nomes das tabelas, fonte única
│ ├── models/ # modelos ORM SQLAlchemy
│ └── repositories/ # camada de acesso a dados
├── ui/ (opcional) # camada de interface — só com o extra [ssr]
│ ├── pages/ # uma classe por tela
│ ├── layout/ # o chrome que toda página herda
│ ├── components/ # peças reutilizáveis
│ └── styles.py # a folha de estilo tipada do serviço
├── utils/ (opcional) # helpers stateless compartilhados
├── queue/ (opcional) # consumers/publishers FastStream
└── tasks/ (opcional) # tarefas em background TaskIQ
db/configs/names.py evita uma classe de erro
Guardar o nome de cada tabela numa constante faz o __tablename__ e
a string da ForeignKey saírem do mesmo símbolo — renomear vira uma
linha, em vez de uma caça a strings espalhadas. Errar uma ponta não
falha na hora: o SQLAlchemy só resolve o alvo da FK ao montar os
mapeamentos, então o erro chega na subida da aplicação, ou dentro de
uma migração apontando para tabela que não existe mais.
Desde a v0.291.0 o tempest new já escreve o arquivo com o nome
da tabela de usuário, e o CLAUDE.md do projeto ensina a receita a
partir dele. Ver
Banco de dados › Centralizando os nomes das tabelas.
Regras inegociáveis
main.pyna raiz do serviço é um one-liner que importarundesrc.server. Nuncasubprocess.run(["uvicorn", ...]).src/server.pyexpõe tanto uma funçãorun()quanto a instânciaappimportável.api/dependencies/é sempre um pacote, nunca um arquivo plano. A auth vive emauth.py; os provedores factory vivem emcontrollers.py(ouservices.pyquando ainda não há camada de controller).- Singletons de infra (db / storage / mail) vivem em
dependencies/resources.py, construídos uma vez (db = AsyncDatabaseManager(**settings.database_kwargs())) e acessados via provedoresget_db/get_session/get_storage/get_mailer. Oapp.pyimporta esses recursos para o lifespan e o wiring dos routers — não os constrói inline. Isso mantém oapp.pymagro e dá um único dono do ciclo de vida dos recursos. - Routers recebem controllers (e sessões/recursos) via
Depends, nunca construídos inline. - Endpoints meta (
/health,/tool-spec) ficam no prefixo raiz; endpoints de negócio ficam sob/api/<domínio>.
A camada de interface (ui/)¶
Quando o serviço entrega HTML — e não só JSON —, ele ganha uma quinta
camada, no mesmo nível de controllers e services. Ela existe
só com o extra [ssr]; sem ele, não crie o pacote vazio.
| Camada | Pode importar | Nunca importa |
|---|---|---|
api/routers |
controllers, ui, schemas |
db |
ui |
schemas, outras partes de ui |
controllers, services, db |
controllers |
services, schemas |
ui |
services |
db/repositories, schemas |
ui |
A página recebe dados prontos
Uma página não busca nada: o router carrega pelo controller e
passa o resultado já materializado. await dentro de body()
significa que uma responsabilidade escorregou de camada.
tempest new <serviço> --extras "ssr" escreve a camada inteira
funcionando (e tempest generate --src a adiciona a um projeto que já
existe). Detalhes em
Camada UI,
Formulários a partir de schemas Pydantic e
CSS tipado.
E quando o frontend é um app, não uma página
A camada ui renderiza HTML no servidor. Quando a tela precisa de
estado no cliente — uma SPA — o caminho continua sendo Python: o
tempestweb escreve a interface e
compila para WASM (ou roda em server-mode), e o SDK serve o build com
make_web_app_router / build_web_app, na mesma origem da API. As duas
opções convivem: Fullstack web compara quando usar
cada uma.
Ciclo de vida da requisição¶
sequenceDiagram
autonumber
participant C as Cliente
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: requisição HTTP
M->>M: vincula X-Request-ID a um contextvar
M->>L: encaminha
L->>L: verifica cota de janela deslizante
L->>R: encaminha
R->>R: valida o schema Pydantic
R->>Ctl: Depends(get_user_controller)
Ctl->>S: orquestra
S->>Repo: filtra / pagina / adiciona
Repo->>DB: SELECT/INSERT/UPDATE
DB-->>Repo: linhas
Repo-->>S: instâncias ORM
S-->>Ctl: ResponseSchema
Ctl-->>R: ResponseSchema
R->>M: serializa Pydantic → JSON
M-->>C: resposta HTTP + header X-Request-ID
Cada passo tem um dono claro — o router nunca conversa com o SQLAlchemy, o repository nunca levanta exceções HTTP (ele levanta a not_found_exception configurada no __init__, e o exception handler a transforma no envelope JSON).
Envelope de exceções¶
O SDK traz AppException + register_exception_handlers para que todo erro no seu serviço serialize no mesmo formato JSON:
{
"detail": "Usuário não encontrado",
"code": "USER_NOT_FOUND",
"details": {"user_id": "01923..."}
}
O frontend ramifica no code (estável, legível por máquina), nunca no detail (que pode estar traduzido).
O 500 também passa pelo CORS¶
Uma exceção que nenhum handler trata vira 500 com code: "INTERNAL_SERVER_ERROR". O Starlette executa o handler de Exception no ServerErrorMiddleware, a camada mais externa da pilha, então sozinho esse envelope sairia sem passar pelo CORSMiddleware: sem Access-Control-Allow-Origin, o navegador descarta a resposta e o fetch rejeita com TypeError: Failed to fetch, sem status, sem code e sem X-Request-ID.
Por isso o register_exception_handlers instala também o ErrorEnvelopeMiddleware como o middleware mais interno. Dali o 500 passa por todo middleware que você adicionou, e a ordem das chamadas deixa de importar:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from tempest_fastapi_sdk import (
RequestIDMiddleware,
apply_cors,
register_exception_handlers,
)
app: FastAPI = FastAPI()
app.add_middleware(RequestIDMiddleware)
apply_cors(app, origins=["https://front.example"])
register_exception_handlers(app)
@app.get("/boom")
async def boom() -> None:
raise RuntimeError("falha não tratada")
client: TestClient = TestClient(app, raise_server_exceptions=False)
response = client.get("/boom", headers={"Origin": "https://front.example"})
print(response.status_code, response.headers["access-control-allow-origin"])
print(response.json()["details"]["request_id"] == response.headers["x-request-id"])
O que continua igual
- A camada não decide CORS. Uma origem fora da lista continua sem o header, também no 500.
- Depois de enviar o envelope, a exceção é re-levantada, como o Starlette já fazia: o servidor ASGI continua logando, e um
TestClientcom o defaultraise_server_exceptions=Truecontinua levantando no seu teste. - Uma falha depois que a resposta começou (stream quebrado no meio) é re-levantada sem segunda resposta, porque o status já foi para a rede. Ela é logada e chega ao
on_server_erroruma vez, como o 500 comum. register_exception_handlersprecisa rodar antes de a aplicação subir; depois disso a pilha está montada e ele levantaRuntimeError.
Para onde ir agora¶
| Você quer… | Leia |
|---|---|
| Construir uma feature passo a passo | Tutorial » |
| Conectar um helper específico | Receitas » |
| Consultar a assinatura de uma classe | Referência » |
| Atualizar de uma versão antiga | Guia de migração » |
Camadas de controllers & services¶
BaseService[RepositoryT, ResponseT] e BaseController[ServiceT, ResponseT] são esqueletos genéricos que casam com o fatiamento do SDK (router → controller → service → repository). Eles expõem métodos CRUD pass-through para que endpoints simples possam herdar deles sem sobrescrever nada; você sobrescreve apenas os métodos que precisam de orquestração.
O que você herda ao subclassear BaseService[RepositoryT, ResponseT]:
| Método | Retorna | Notas |
|---|---|---|
get_by_id(id) |
ResponseT |
Aguarda repository.get_by_id + repository.map_to_response. Levanta repository.not_found_exception quando não encontra. |
get_or_none(filters) |
ResponseT | None |
Mesmo formato, retorna None em vez de levantar. |
list(filters=None, order_by=None, ascending=True) |
list[ResponseT] |
Retorna [] quando não há correspondência (nunca levanta). |
paginate(filters=None, order_by=None, page=1, page_size=20, ascending=True) |
dict com items mapeados + total/page/page_size/pages. |
Paginação por offset via repository.paginate. |
count(filters=None) |
int |
Pass-through para repository.count. |
exists(filters) |
bool |
Pass-through para repository.exists. |
update(id, data) |
ResponseT |
Busca por id, copia os campos presentes em data (tipado por UpdateT, o 3º parâmetro genérico opcional — default BaseSchema) na instância, persiste e mapeia. to_dict() descarta unset/None, então serve PUT e PATCH. |
delete(id) |
None |
Hard delete via repository.delete. |
map_to_response é aguardado com await quando retorna uma coroutine, então mappers async funcionam de forma transparente — sem precisar sobrescrever o método.
O que você herda ao subclassear BaseController[ServiceT, ResponseT]:
| Método | Encaminha para | Notas |
|---|---|---|
get_by_id(id) |
service.get_by_id |
Mesmo tipo de retorno do service. |
list(filters, order_by, ascending) |
service.list |
Igual. |
paginate(filters, order_by, page, page_size, ascending) |
service.paginate |
Igual. |
count(filters) |
service.count |
Igual. |
update(id, data) |
service.update |
Igual. |
delete(id) |
service.delete |
Igual. |
Tipando o payload de update ponta a ponta
O 3º parâmetro genérico é o schema de update, e ele precisa ser o mesmo nas duas camadas: um service declarado como BaseService[Repo, Resp, MeuUpdateSchema] encaixa em BaseController[MeuService, Resp, MeuUpdateSchema]. Omitir o 3º parâmetro em qualquer uma delas cai no default BaseSchema, e o data de update chega sem tipo preciso.
from tempest_fastapi_sdk import BaseController, BaseService
from src.db.repositories import CoinPackRepository
from src.schemas import CoinPackResponseSchema, CoinPackUpdateSchema
class CoinPackService(
BaseService[CoinPackRepository, CoinPackResponseSchema, CoinPackUpdateSchema]
):
"""Service tipado sobre o schema de update concreto."""
class CoinPackController(
BaseController[CoinPackService, CoinPackResponseSchema, CoinPackUpdateSchema]
):
"""Controller que repete o mesmo schema de update."""
Requer 0.167.1+
Em versões anteriores esse par falhava no type check com Type parameter "UpdateT@BaseService" is invariant, but "CoinPackUpdateSchema" is not the same as "BaseSchema" — o bound de ServiceT deixava o parâmetro invariante fixado em BaseSchema. Corrigido em 0.167.1; nada muda em runtime.
Quando um caso de uso precisa de regras de domínio, sobrescreva o método herdado no service. Quando um caso de uso precisa coordenar mais de um service, sobrescreva o método herdado (ou adicione um novo) no controller. O router nunca cresce — ele só depende do 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.dependencies.resources 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 é uma subclasse de BaseRepository[UserModel] cujo
# __init__ injeta `model=UserModel` via super().__init__(session, model=UserModel).
# Veja o tutorial para o esqueleto completo:
# https://mauriciobenjamin700.github.io/tempest-fastapi-sdk/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)
Mantenha os controllers presentes mesmo quando só fazem pass-through — o grafo de imports fica uniforme entre os serviços, então adicionar política transversal mais tarde não muda a assinatura do router.
Controller que orquestra — Controller¶
BaseController recebe um service e repassa o CRUD dele. Um controller
que coordena vários services — emitir a fatura, lançar no razão, notificar —
não tem get_by_id nem delete de um recurso só; herdar BaseController ali
entregaria ao router métodos que não significam nada. Para esse caso existe
Controller, a raiz da camada: sem método
nenhum, só a convenção — services injetados pelo __init__, nenhum acesso
a banco (sem sessão, sem repository). BaseController é a especialização CRUD
dela, então isinstance(controller, Controller) vale para os dois.
| O seu controller… | Herde |
|---|---|
| repassa o CRUD de um recurso, via um service | BaseController[ServiceT, ResponseT, UpdateT] |
| coordena vários services, sem CRUD de recurso único | Controller |
# src/controllers/billing_controller.py
from uuid import UUID
from tempest_fastapi_sdk import Controller
from src.schemas import ChargeResponse
from src.services import InvoiceService, LedgerService, NotificationService
class BillingController(Controller):
"""Charge = invoice + ledger posting + notification."""
def __init__(
self,
invoices: InvoiceService,
ledger: LedgerService,
notifications: NotificationService,
) -> None:
self.invoices: InvoiceService = invoices
self.ledger: LedgerService = ledger
self.notifications: NotificationService = notifications
async def charge(self, customer_id: UUID, amount_cents: int) -> ChargeResponse:
invoice = await self.invoices.issue(customer_id, amount_cents)
await self.ledger.post(invoice.id, amount_cents)
await self.notifications.invoice_issued(customer_id, invoice.id)
return ChargeResponse(invoice_id=invoice.id, amount_cents=amount_cents)
# src/api/dependencies/controllers.py
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.resources import db
from src.controllers.billing_controller import BillingController
from src.db.repositories import InvoiceRepository, LedgerRepository
from src.services import InvoiceService, LedgerService, NotificationService
def get_billing_controller(
session: AsyncSession = Depends(db.session_dependency),
) -> BillingController:
return BillingController(
InvoiceService(InvoiceRepository(session)),
LedgerService(LedgerRepository(session)),
NotificationService(),
)
O provider monta os services, o controller só os recebe — a sessão fica no provider e nos repositories, nunca no controller.
A convenção não é checada em runtime
O __init__ é da subclasse, e distinguir service de repository pela
anotação exigiria resolver as forward references dela. Quem garante a
regra é a revisão.
Padrões de service, repository e controller¶
As seções acima dizem o que cada camada herda. Esta responde as
perguntas que aparecem no primeiro serviço de verdade: onde mora o mapper,
o que fazer quando o service precisa de dois repositories, como a
session, as settings e o mailer chegam até o controller, onde a checagem
de permissão roda, e o que o router devolve.
Tudo num exemplo só, completo — copie para um arquivo e rode com
uv run python app.py. Num projeto, cada classe mora na sua camada
(db/models/, schemas/, core/exceptions.py, db/repositories/,
services/, controllers/, api/dependencies/controllers.py,
api/routers/); aqui estão juntas para o exemplo rodar sozinho.
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from uuid import UUID
from fastapi import APIRouter, Depends, FastAPI, status
from fastapi.testclient import TestClient
from pydantic import Field
from sqlalchemy import ForeignKey, String
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
BaseAppSettings,
BaseController,
BaseModel,
BaseRepository,
BaseResponseSchema,
BaseSchema,
BaseService,
NotFoundException,
ValidationException,
register_exception_handlers,
)
class Settings(BaseAppSettings):
"""Service configuration."""
MAX_PRICE_CENTS: int = 100_000
class CategoryModel(BaseModel):
"""A catalogue category."""
name: Mapped[str] = mapped_column(String(64))
class ProductModel(BaseModel):
"""A product, always inside a category."""
name: Mapped[str] = mapped_column(String(64))
price_cents: Mapped[int]
category_id: Mapped[UUID] = mapped_column(ForeignKey("category.id"))
class ProductCreateSchema(BaseSchema):
"""Product creation payload."""
name: str = Field(min_length=1, max_length=64)
price_cents: int = Field(gt=0)
category_id: UUID
class ProductResponseSchema(BaseResponseSchema):
"""A product as the API returns it."""
name: str
price_cents: int
category_id: UUID
class CategoryNotFoundError(NotFoundException):
"""The given category does not exist."""
code: str = "CATEGORY_NOT_FOUND"
class ProductNotFoundError(NotFoundException):
"""The given product does not exist."""
code: str = "PRODUCT_NOT_FOUND"
class ProductPriceAboveLimitError(ValidationException):
"""The price is above the configured ceiling."""
code: str = "PRODUCT_PRICE_ABOVE_LIMIT"
class CategoryRepository(BaseRepository[CategoryModel]):
"""Data access for categories."""
def __init__(self, session: AsyncSession) -> None:
"""Bind the repository to the session.
Args:
session (AsyncSession): The request-scoped session.
"""
super().__init__(
session,
model=CategoryModel,
not_found_exception=CategoryNotFoundError,
)
class ProductRepository(BaseRepository[ProductModel]):
"""Data access for products; owns the response mapper."""
def __init__(self, session: AsyncSession) -> None:
"""Bind the repository to the session.
Args:
session (AsyncSession): The request-scoped session.
"""
super().__init__(
session,
model=ProductModel,
not_found_exception=ProductNotFoundError,
)
def map_to_response(self, instance: ProductModel) -> ProductResponseSchema:
"""Map a row to the response schema.
Args:
instance (ProductModel): The ORM row.
Returns:
ProductResponseSchema: The product, ready for the API.
"""
return ProductResponseSchema.model_validate(instance)
class ProductService(BaseService[ProductRepository, ProductResponseSchema]):
"""Business rules for products."""
def __init__(
self,
repository: ProductRepository,
*,
categories: CategoryRepository,
max_price_cents: int,
) -> None:
"""Take the main repository plus what the creation rule reads.
Args:
repository (ProductRepository): The resource's own repository.
categories (CategoryRepository): Read to validate the category.
max_price_cents (int): Price ceiling, taken from the settings.
"""
super().__init__(repository)
self.categories: CategoryRepository = categories
self.max_price_cents: int = max_price_cents
async def create(self, data: ProductCreateSchema) -> ProductResponseSchema:
"""Create a product in an existing category, under the price ceiling.
Args:
data (ProductCreateSchema): The creation payload.
Returns:
ProductResponseSchema: The created product.
Raises:
CategoryNotFoundError: When the category does not exist.
ProductPriceAboveLimitError: When the price is above the ceiling.
"""
await self.categories.get_by_id(data.category_id)
if data.price_cents > self.max_price_cents:
raise ProductPriceAboveLimitError(
details={"max_price_cents": self.max_price_cents},
)
instance = self.repository.map_to_model(data.to_dict())
instance = await self.repository.add(instance)
return self.repository.map_to_response(instance)
class ProductController(BaseController[ProductService, ProductResponseSchema]):
"""Product use cases; a pass-through for now."""
async def create(self, data: ProductCreateSchema) -> ProductResponseSchema:
"""Create a product.
Args:
data (ProductCreateSchema): The creation payload.
Returns:
ProductResponseSchema: The created product.
"""
return await self.service.create(data)
settings: Settings = Settings()
db: AsyncDatabaseManager = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
def get_settings() -> Settings:
"""Return the process settings.
Returns:
Settings: The single instance, replaceable through ``dependency_overrides``.
"""
return settings
def get_product_controller(
session: AsyncSession = Depends(db.session_dependency),
app_settings: Settings = Depends(get_settings),
) -> ProductController:
"""Build repository, service and controller for one request.
Args:
session (AsyncSession): The request-scoped session.
app_settings (Settings): The process settings.
Returns:
ProductController: The controller bound to that session.
"""
service = ProductService(
ProductRepository(session),
categories=CategoryRepository(session),
max_price_cents=app_settings.MAX_PRICE_CENTS,
)
return ProductController(service)
router: APIRouter = APIRouter(prefix="/api/products", tags=["products"])
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_product(
data: ProductCreateSchema,
controller: ProductController = Depends(get_product_controller),
) -> ProductResponseSchema:
"""Create a product.
Args:
data (ProductCreateSchema): The creation payload.
controller (ProductController): Injected by the provider.
Returns:
ProductResponseSchema: The created product.
"""
return await controller.create(data)
@router.get("/{product_id}")
async def get_product(
product_id: UUID,
controller: ProductController = Depends(get_product_controller),
) -> ProductResponseSchema:
"""Fetch a product by id.
Args:
product_id (UUID): The product id.
controller (ProductController): Injected by the provider.
Returns:
ProductResponseSchema: The product.
"""
return await controller.get_by_id(product_id)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Create the tables and one sample category.
Args:
app (FastAPI): The application.
Yields:
None: While the application runs.
"""
await db.create_tables()
async with db.get_session_context() as session:
session.add(CategoryModel(id=UUID(int=1), name="Books"))
await session.commit()
yield
await db.disconnect()
app: FastAPI = FastAPI(lifespan=lifespan)
register_exception_handlers(app)
app.include_router(router)
with TestClient(app) as client:
created = client.post(
"/api/products",
json={"name": "Dune", "price_cents": 5990, "category_id": str(UUID(int=1))},
)
print(created.status_code, created.json()["name"])
fetched = client.get(f"/api/products/{created.json()['id']}")
print(fetched.status_code, fetched.json()["price_cents"])
missing = client.post(
"/api/products",
json={"name": "Dune", "price_cents": 5990, "category_id": str(UUID(int=2))},
)
print(missing.status_code, missing.json()["code"])
too_high = client.post(
"/api/products",
json={"name": "Rare", "price_cents": 500_000, "category_id": str(UUID(int=1))},
)
print(too_high.status_code, too_high.json()["code"], too_high.json()["details"])
app.dependency_overrides[get_settings] = lambda: Settings(MAX_PRICE_CENTS=1_000)
capped = client.post(
"/api/products",
json={"name": "Dune", "price_cents": 5990, "category_id": str(UUID(int=1))},
)
print(capped.status_code, capped.json()["details"])
201 Dune
200 5990
404 CATEGORY_NOT_FOUND
422 PRODUCT_PRICE_ABOVE_LIMIT {'max_price_cents': 100000}
422 {'max_price_cents': 1000}
Vamos por partes.
O mapper mora no repository¶
ProductRepository.map_to_response é a única tradução de linha ORM para
schema de resposta do recurso. O BaseService chama esse método em todo
caminho que devolve dado mapeado — get_by_id, get_or_none, list,
paginate e update —, então o get_product do exemplo funciona sem uma
linha de service: o get_by_id herdado busca a linha e passa pelo mapper.
O contrato, como o código está hoje:
Método do BaseRepository |
Padrão da base | Quem chama |
|---|---|---|
map_to_response(instance) |
levanta NotImplementedError("Subclasses must implement map_to_response") |
BaseService.get_by_id / get_or_none / list / paginate / update |
map_to_model(data) |
self.model(**data) |
o seu service, ao criar (self.repository.map_to_model(data.to_dict())) |
map_to_schema(instance) |
levanta NotImplementedError |
ninguém no SDK; existe para o seu código |
Não é método abstrato no sentido de abc: o repository sem mapper instancia
normalmente, e count, exists e delete funcionam. A falha aparece na
primeira chamada que mapeia — no HTTP, um 500. Por isso todo repository
entregue a um BaseService implementa map_to_response; o
CategoryRepository do exemplo não implementa porque nenhum BaseService
o recebe — o ProductService só chama get_by_id nele, que devolve a linha
sem mapear.
Mapper async
Quando o mapeamento precisa de await (carregar uma relação, por
exemplo), declare async def map_to_response. O BaseService aguarda o
resultado quando ele é awaitable — nada para sobrescrever no service.
Por que no repository, e não num helper do service: o mapper fica ao lado da
query que carregou a linha, e o BaseService só consegue entregar o CRUD
herdado porque sabe onde procurá-lo. Um service que reescreve
list como [self.repository.map_to_response(x) for x in ...] está
reimplementando a base.
Um repository por service — e quando são vários¶
BaseService[RepositoryT, ResponseT, UpdateT] recebe um repository no
__init__: o do recurso cujo CRUD ele herda. O terceiro parâmetro genérico é
opcional (veja o tip acima). Quando um
caso de uso precisa de mais dados, a pergunta é de quem é a regra:
| Situação | Onde o segundo repository entra |
|---|---|
| A regra é deste recurso e só lê ou grava junto outro dado — validar que a categoria existe, gravar pedido e itens | parâmetro keyword-only no __init__ do service, depois do super().__init__(repository) — como categories no exemplo |
| O outro dado tem regras próprias e já tem service | o controller recebe os dois services e orquestra (veja Controller) |
| O service não tem repository nenhum (catálogo estático, fachada de API externa) | sem BaseService; marque a classe com # tempest: no-base-service |
O __init__ do ProductService acrescenta dependências, então não é o
__init__ redundante que o SVC003 acusa — ele só acusa o que guarda o
repository e nada mais.
Duas escritas, uma transação
Cada método de escrita do BaseRepository faz COMMIT sozinho. Quando a
regra grava em dois repositories, abra async with
self.repository.transaction(): em volta das duas escritas. O bloco vale
para todo repository da mesma AsyncSession — e é a fábrica que
garante isso, montando os dois sobre a sessão da requisição. Detalhes em
Transações.
Controllers chegam por fábricas em Depends¶
get_product_controller é o único lugar que sabe montar o grafo: abre a
sessão da requisição, lê as settings, constrói os dois repositories, o
service e o controller. O router só declara
controller: ProductController = Depends(get_product_controller).
Num projeto, as fábricas moram em src/api/dependencies/controllers.py, e
os recursos de processo (db, storage, mailer) em
src/api/dependencies/resources.py. Cada dependência da fábrica é outra
Depends, nunca um import lido direto no meio do corpo — é o que faz a
última requisição do exemplo funcionar: um dependency_overrides[get_settings]
trocou o teto de preço sem tocar em router, controller ou service. O mesmo
vale para o mailer:
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import EmailUtils
from src.api.dependencies.resources import get_mailer, get_session
from src.controllers import InviteController
from src.core.settings import Settings, get_settings
from src.db.repositories import InviteRepository
from src.services import InviteService
def get_invite_controller(
session: AsyncSession = Depends(get_session),
mailer: EmailUtils = Depends(get_mailer),
settings: Settings = Depends(get_settings),
) -> InviteController:
"""Build the invite controller for one request.
Args:
session (AsyncSession): The request-scoped session.
mailer (EmailUtils): The process-wide mailer.
settings (Settings): The process settings.
Returns:
InviteController: The controller wired to those resources.
"""
service = InviteService(
InviteRepository(session),
mailer=mailer,
code_secret=settings.INVITE_CODE_SECRET,
)
return InviteController(service)
O que a fábrica evita é o router montando o controller —
InviteController(session, mailer=mailer, code_secret=settings.X) dentro do
handler. Ali o router passa a conhecer sessão, settings e a assinatura de
três classes, e cada endpoint que usa o controller repete a montagem.
O que o tempest new gera
O scaffold já sai neste formato. src/api/dependencies/controllers.py
traz get_service_info_controller, a fábrica do GET /api/
placeholder, que recebe Settings por Depends(get_settings); o router
declara controller: ServiceInfoController = Depends(get_service_info_controller).
src/core/settings.py expõe get_settings() com @lru_cache — o
settings de módulo continua lá, é a mesma instância, para o que roda
fora de requisição (recursos, logging, lifespan, server.py, Alembic).
src/api/dependencies/resources.py traz db, get_db e get_session
sempre, get_mailer só com [email] e get_storage só com [minio]
— e, com eles, o EmailSettings / StorageSettings no Settings. O
tests/test_smoke.py gerado troca o TITLE por
dependency_overrides[get_settings] e confere que o GET /api/
responde o valor novo.
Guards: dependência no router ou @requires no método¶
O SDK oferece dois lugares para checar permissão, e eles respondem perguntas diferentes:
| Ferramenta | Pergunta | Onde roda |
|---|---|---|
make_token_dependency, make_jwt_user_dependency, make_role_dependency, make_permission_dependency |
"quem chama?" / "o token carrega este papel ou esta permissão?" | dependência de rota, antes do handler |
@requires(guard, ...) com require_authenticated, require_active, require_admin, make_flag_guard, make_value_guard ou um guard seu |
"este usuário, já carregado, passa por esta invariante?" | qualquer função que recebe o usuário — handler, controller ou service |
O caminho que mantém o router sem regra: autenticação na dependência,
invariante no método do caso de uso. A dependência resolve o usuário e o
router o repassa; o @requires fica no método do controller, de modo que
quem chamar o caso de uso — rota HTTP, consumer de fila, comando de CLI —
passa pela mesma checagem.
import asyncio
from uuid import UUID
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import (
AppException,
AsyncDatabaseManager,
BaseController,
BaseModel,
BaseRepository,
BaseResponseSchema,
BaseService,
BaseUserModel,
requires,
)
from tempest_fastapi_sdk.auth import require_admin
class AccountModel(BaseUserModel):
"""An account that calls the API."""
class ProductModel(BaseModel):
"""A catalogue product."""
name: Mapped[str] = mapped_column(String(64))
class ProductResponseSchema(BaseResponseSchema):
"""A product as the API returns it."""
name: str
class ProductRepository(BaseRepository[ProductModel]):
"""Data access for products."""
def __init__(self, session: AsyncSession) -> None:
"""Bind the repository to the session.
Args:
session (AsyncSession): The active session.
"""
super().__init__(session, model=ProductModel)
def map_to_response(self, instance: ProductModel) -> ProductResponseSchema:
"""Map a row to the response schema.
Args:
instance (ProductModel): The ORM row.
Returns:
ProductResponseSchema: The product, ready for the API.
"""
return ProductResponseSchema.model_validate(instance)
class ProductService(BaseService[ProductRepository, ProductResponseSchema]):
"""Business rules for products."""
class ProductController(BaseController[ProductService, ProductResponseSchema]):
"""Product use cases."""
@requires(require_admin)
async def remove(self, product_id: UUID, user: AccountModel) -> None:
"""Delete a product; administrators only.
Args:
product_id (UUID): The product to delete.
user (AccountModel): The caller, already authenticated.
"""
await self.service.delete(product_id)
async def main() -> None:
"""Call the use case as a regular account, then as an administrator."""
db: AsyncDatabaseManager = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.create_tables()
admin = AccountModel(email="ana@example.com", hashed_password="x", is_admin=True)
member = AccountModel(email="bia@example.com", hashed_password="x", is_admin=False)
async with db.get_session_context() as session:
repository = ProductRepository(session)
product = await repository.add(ProductModel(name="Dune"))
controller = ProductController(ProductService(repository))
try:
await controller.remove(product.id, member)
except AppException as exc:
print(exc.status_code, exc.code)
await controller.remove(product.id, admin)
print("left:", await controller.count())
await db.disconnect()
asyncio.run(main())
O guard achou o usuário pela anotação (AccountModel herda
BaseUserModel), negou a conta comum levantando ForbiddenException — que o
register_exception_handlers transforma no envelope 403 — e deixou o
administrador passar. Contrato completo do guard, meta= e o linter
tempest permissions em Guards de permissão.
O router devolve schema¶
Todo handler do exemplo anota o retorno com o schema de resposta
(-> ProductResponseSchema) e devolve o que o controller entregou. Com o
mapper no repository, o schema já sai pronto do service — o router não
importa nada de db/ e não vê linha ORM. O FastAPI usa a anotação de
retorno como response_model, então o OpenAPI documenta o schema sem
response_model= repetido no decorator.
O que o tempest lint-layers confere¶
Parte destas regras tem gate; parte não. O tempest lint-layers (e o passo
lint-layers do tempest check) lê o código com ast e acusa:
| Código | O que acusa |
|---|---|
SVC001 |
classe pública em services/ que não herda BaseService |
SVC002 |
segunda classe pública ou função de módulo num arquivo de services/ |
SVC003 |
__init__ de service que só guarda o repository |
REPO001 |
método de repository que só repassa para um método do BaseRepository |
ENUM001 |
BaseStrEnum cujo nome não termina em Enum |
RTR001 |
classe *Controller / *Service chamada dentro de routers/ em vez de chegar por Depends |
RTR002 |
rota cuja anotação de retorno ou response_model= cita um model ORM |
Rodado sobre um src/ com um FamilyService cujo __init__ só chama
super().__init__(repository), um ProductService como o do exemplo e um
class UserRole(BaseStrEnum), o resultado é este (caminhos encurtados):
src/core/enums.py:4: ENUM001 `UserRole` inherits BaseStrEnum; rename it to `UserRoleEnum`. The database type name does not change (enum_type_name drops one trailing `Enum`), so no migration is needed.
src/services/family.py:5: SVC003 `FamilyService.__init__` only stores the repository, which BaseService already does; delete it.
O ProductService, que acrescenta categories e max_price_cents, passa
limpo. Sobre o ENUM001: a partir da v0.313.0 o enum_type_name tira um
Enum do fim do nome da classe antes de acrescentar _enum, então
UserRole e UserRoleEnum dão o mesmo tipo user_role_enum — renomear a
classe para o sufixo não pede TempestEnum(UserRoleEnum, name="user_role_enum")
em cada coluna. Banco criado antes, com o nome dobrado, está em
Colunas enum › Renomear o tipo.
Troque o Depends(get_product_controller) do router por
ProductController(ProductService(ProductRepository(session))) e a mesma
execução acusa RTR001 naquela linha, uma vez por classe montada; anote a
rota com -> ProductModel e ela acusa RTR002.
Fica sem gate, de propósito, uma regra: onde o guard fica. O
@requires vale tanto no handler da rota quanto no método do controller —
a receita de guards de permissão usa a rota
como primeira forma —, então uma checagem que acusasse a rota acusaria
código correto. Escolher o método do controller, como acima, é revisão.
Tabela completa, o opt-out # tempest: router-builds e o que as regras de
router não veem em
CLI › Regras de camada.
Recap¶
- Mapper no repository.
map_to_responseé obrigatório para todo repository entregue a umBaseService; pode serasync. - Um repository por
BaseService. Dado extra que a regra deste recurso lê ou grava junto entra como keyword-only no__init__; recurso com regra própria tem service próprio, e o controller orquestra. - Escritas em dois repositories vão dentro de
self.repository.transaction(), sobre a mesma sessão. - Controller vem de fábrica em
api/dependencies/controllers.py, que recebe sessão, settings e mailer porDepends. - Autenticação na dependência da rota; invariante com
@requiresno método do caso de uso. - Router devolve schema, anotado no retorno.
tempest lint-layersconfereSVC001–SVC003,REPO001,ENUM001,RTR001eRTR002; onde o guard fica é revisão.