Backend¶
FastAPI · SQLAlchemy (async) · Alembic · Pydantic · pytest · ruff · mypy (strict).
Molde: app/domains/users/.
Abra os arquivos dele antes de escrever um domínio novo. Ele é canônico na forma —
camadas, injeção, schemas, documentação da rota. Os campos dele valem para usuário,
não para o assunto novo.
Anatomia de um domínio ✅¶
app/domains/<nome>/, uma pasta por domínio:
| Arquivo | Faz | Não faz |
|---|---|---|
router.py |
recebe, valida pelo schema, chama o service, traduz erro de domínio em status HTTP | regra de negócio, query, importar models |
service.py |
decide, orquestra, levanta erro de domínio | importar fastapi ou sqlalchemy, montar query |
repository.py |
consulta, agrega, grava (com flush) |
decidir regra, levantar erro de domínio |
schemas.py |
Pydantic, um schema por direção, e o de_model() que monta a resposta |
— |
models.py |
a tabela SQLAlchemy | validar entrada |
dependencies.py |
monta a cadeia sessão → repository → service e exporta ServiceDep |
— |
Arquivo nasce quando tem conteúdo: domínio sem rota não tem router.py, e utils.py
vazio não entra.
A pergunta que resolve o empate¶
Se a resposta muda quando o produto muda de ideia, é service. Se muda quando o banco muda de forma, é repository.
"Já existe usuário com esse e-mail? Então 409" é produto: service. "Como eu descubro se existe" é banco: repository.
O que o CI cobra 🔒¶
O arquivo tests/test_arquitetura.py
roda no pytest e quebra o check qualidade. Vermelho ali não é estilo: é camada furada.
| Regra | O que o teste procura |
|---|---|
| Service não conhece HTTP nem ORM | import de fastapi ou sqlalchemy, select( ou self.db no service.py |
| Router não conhece a tabela | import de .models ou sqlalchemy no router.py |
| Repository não decide regra | shared.exceptions ou HTTPException no repository.py |
| Domínio não importa domínio | from app.domains.<outro> fora das exceções declaradas no próprio teste |
commit() só no get_db |
.commit() em qualquer arquivo fora de app/core/database.py |
Sessão só pelo SessionDep |
Depends(get_db solto em qualquer arquivo |
Todo models.py está registrado |
o domínio falta em app/models.py — sem isso, o autogenerate não vê a tabela |
Reexportar tipo de models.py é declarado |
schemas.py com __all__ reexportando um Enum do model, sem motivo escrito no teste |
| Não existe util global | o arquivo app/shared/utils.py |
E o tests/test_openapi.py
cobra a documentação da API:
| Regra | Vale para |
|---|---|
Toda rota tem summary, description e operation_id |
toda rota, inclusive a sua nova |
Toda tag usada por uma rota tem descrição em OPENAPI_TAGS (app/main.py) |
todo domínio novo com rota |
Campos com description e examples, respostas de erro documentadas |
as rotas listadas no teste. Rota nova segue a mesma forma ✅ — veja o router.py do molde |
Exceção legítima¶
Quando uma regra precisa de exceção de verdade, ela entra na lista do próprio teste, com o motivo escrito ao lado. Hoje existem três listas:
| Lista | Permite |
|---|---|
COMPOE_COM_SERVICE_DE |
um domínio usar o service de outro (ex.: users lê o papel pelo LinkService) |
LEITURA_ENTRE_DOMINIOS |
um domínio de leitura fazer JOIN em tabela alheia no repository — vazia por enquanto |
REEXPORT_DE_TIPO_PERMITIDO |
schemas.py reexportar um Enum do model |
Acrescentar uma linha numa dessas listas é decisão de arquitetura: o revisor vai perguntar por quê.
Erros¶
O vocabulário está em app/shared/exceptions.py:
NotFoundError, ConflictError, ValidationError, ForbiddenError,
AuthenticationError, todos filhos de DomainError.
- O service levanta o erro de domínio. Ele não sabe o que é status code.
- O router captura e traduz:
except ConflictError as exc: raise HTTPException(status.HTTP_409_CONFLICT, exc.message) from exc. - O repository não levanta erro de domínio: devolve
None, e o service decide se aquilo é 404. 🔒
Transação¶
A unidade de trabalho é a requisição HTTP. O get_db() de app/core/database.py
faz commit no fim da requisição e rollback em qualquer exceção.
| Camada | Faz |
|---|---|
repository.py |
flush() quando precisa do id gerado; nunca commit() 🔒 |
service.py |
nada: não abre nem fecha transação |
router.py |
nada |
Schemas ✅¶
- Um schema por direção. Entrada e saída nunca são o mesmo schema:
UserCreateentra,UserResponsesai. Campo gerado pelo servidor (id,created_at) existe só na saída. - Sufixos:
<Entidade>Create,<Entidade>Update,<Entidade>Response. Um campo comum aos dois lados pode subir para<Entidade>Base. - A montagem da resposta é um
@classmethod de_model()no schema de saída, nunca no router. - Campo documentado:
Field(description=..., examples=[...]). É o que aparece no Swagger e o que o front lê para escrever o tipo.
Onde mora o compartilhado¶
| O quê | Onde |
|---|---|
| Função pura de um domínio só | app/domains/<x>/utils.py — é aqui que tudo nasce |
| Constante nomeada de um domínio só | app/domains/<x>/constants.py |
| Erros de domínio | app/shared/exceptions.py |
Guarda de papel (require_role) |
app/shared/authorization.py |
| Enums compartilhados, schemas de erro, validadores | app/shared/enums.py, schemas.py, validators.py |
| Paginação | app/shared/paginacao.py (PaginaDe[T]) |
| Config, sessão de banco | app/core/ — é fundação, não conhece domínio nenhum |
| Cliente de serviço de fora | app/external_services/<serviço>/ |
Regra dos dois usos: o primeiro uso mora no domínio; no segundo domínio que
precisar, sobe para app/shared/<assunto>.py. Arquivo de compartilhado tem nome de
assunto. app/shared/utils.py é proibido 🔒 — "utils" não é assunto.
Serviços externos¶
Pacote irmão de domains/, com a forma de um domínio menos o que ele não tem: sem rota,
sem tabela, sem regra. O exemplo existente é
app/external_services/keycloak/.
- Só o
service.pydo domínio dono chama o cliente — nunca o router. - Toda chamada tem timeout, vindo de
settings(KEYCLOAK_TIMEOUT_SECONDS). - Erro de biblioteca (
httpx.HTTPError) morre no cliente: o que sai dele é erro com nome do serviço (KeycloakUnavailableError), e o service decide o status. - Teste não toca a rede: o cliente tem um ponto de troca (
build_http_client) para o teste injetar um falso.
O webhook que um serviço externo chama de volta é rota do domínio dono — quem entra pela porta HTTP entra pelo domínio.
Leitura entre domínios¶
| Operação | Domínio de leitura (dashboards, relatorios) |
Demais domínios |
|---|---|---|
| Usar dado de outro domínio | JOIN no repository, declarado em LEITURA_ENTRE_DOMINIOS |
pelo service.py do dono, declarado em COMPOE_COM_SERVICE_DE |
| Escrever em tabela de outro domínio | nunca | nunca — pelo service do dono |
Domínios que ainda são casca¶
organizacoes, prismas, prognosticos, relatorios e dashboards têm a pasta e um
router.py marcado como STUB: a rota está registrada e responde 404. insights e
documents têm tabela, mas nenhuma rota. Antes de integrar o front com um domínio, abra
o router.py dele — ver Contrato front ↔ back.
Ainda em consolidação 🟡¶
- Nome dos métodos. Repository grava com
createem quatro domínios e cominsertem três; service misturacreate,create_user_serviceeget_link_by_id_service. Até o time decidir, siga o domínio que você está mexendo. de_model×from_model, e oFormReadno lugar deFormResponse: uma exceção cada no código.
Detalhes e contagem em Em consolidação.
Passo a passo para criar um domínio: Criar um domínio no back.