API REST/GraphQL de gerenciamento acadêmico desenvolvida com NestJS, TypeORM e PostgreSQL, seguindo princípios de arquitetura hexagonal (um estilo de organização de código onde a lógica de negócio fica isolada no centro, sem depender de frameworks ou bancos de dados — explicado em detalhes na seção Arquitetura).
Ambiente de desenvolvimento público: https://dev.ladesa.com.br/api/v1/docs/
O Ladesa (Laboratório de Desenvolvimento de Software Acadêmico) é um ecossistema de software voltado para a gestão acadêmica de instituições de ensino. O Management Service é o back-end principal desse ecossistema — a API que centraliza e gerencia todos os dados acadêmicos da plataforma.
O que ele gerencia:
- Estrutura física — campus, blocos e ambientes (salas, laboratórios).
- Estrutura acadêmica — cursos, disciplinas, modalidades, níveis de formação, ofertas de formação e suas etapas.
- Turmas e diários — turmas vinculadas a ofertas, com diários de classe para registro de atividades.
- Horários e calendários — calendários letivos, agendamentos, configuração e geração automática de horários de aula.
- Estágios — empresas, estagiários, estágios e responsáveis.
- Usuários e autenticação — perfis de usuários, autenticação via Keycloak, notificações.
- Armazenamento — upload e gerenciamento de arquivos e imagens.
- Localidades — estados, cidades e endereços (com dados do IBGE).
A aplicação expõe uma API REST (com documentação interativa via Swagger/Scalar) e uma API GraphQL (com playground GraphiQL), permitindo que front-ends e outros serviços consumam os dados de forma flexível.
O que é uma API? API (Application Programming Interface) é uma forma padronizada de dois programas se comunicarem. Neste caso, o front-end (a interface visual que o usuário vê no navegador) envia requisições HTTP para a API, e ela responde com dados em formato JSON. Pense como um garçom: ele recebe pedidos (requisições) e traz pratos (respostas) da cozinha (banco de dados).
Tecnologias principais: roda sobre o runtime Bun (um runtime JavaScript/TypeScript rápido, alternativa ao Node.js), utiliza o framework NestJS v11 (framework que organiza o código em módulos, controllers e serviços — detalhado na seção NestJS — conceitos fundamentais) e persiste dados em PostgreSQL 15 (banco de dados relacional — armazena dados em tabelas com linhas e colunas) via TypeORM 0.3 (ferramenta que traduz objetos TypeScript para tabelas SQL — explicado na seção ORM). A autenticação é delegada a um servidor Keycloak via OAuth2/OIDC (protocolos de autenticação delegada — explicados na seção OAuth2 e OIDC), e a comunicação assíncrona com outros serviços acontece por meio de filas RabbitMQ (um intermediário de mensagens entre serviços — explicado na seção Message broker).
Todo o ambiente de desenvolvimento é containerizado — você não precisa instalar Bun, Node.js, PostgreSQL nem nenhuma outra dependência diretamente na sua máquina.
- Visão geral
- Pré-requisitos
- Clonando o repositório
- Rodando o projeto
- Primeiros passos após o setup
- Acessando a aplicação
- Serviços do ambiente
- Variáveis de ambiente
- Scripts disponíveis
- Banco de dados e migrações
- Autenticação e autorização
- Qualidade de código
- Como contribuir
- Conceitos básicos de Git
- Gitflow do projeto
- Convenções de nomenclatura
- Trabalhando com Git localmente
- Trabalhando localmente no desenvolvimento
- Passo a passo completo
- Ciclo de vida de um PR
- O que fazer vs. o que NÃO fazer
- Como escrever um bom commit
- Como escrever uma boa issue
- Como escrever um bom Pull Request
- Arquitetura
- Principais abstrações e padrões
- Entidade de domínio
- Schemas Zod do domínio
- FieldMetadata e QueryFields
- Interfaces de repositório
- Mappers (mapeamento entre camadas)
- Command e Query Handlers
- Permission Checker
- DeclareDependency e DeclareImplementation
- Scalars semânticos
- TransactionInterceptor e ConnectionProxy
- ZodGlobalValidationPipe
- ApplicationErrorFilter
- Paginação
- GraphQL
- Message broker
- Testes
- CI/CD
- Boas práticas de desenvolvimento
- Princípios de engenharia
- Stack tecnológico
- Dicas e troubleshooting
- Licença
Este projeto roda inteiramente dentro de containers Docker. Antes de instalar as ferramentas, entenda o que são containers:
Um container é um pacote que inclui um sistema operacional mínimo junto com todas as ferramentas, bibliotecas e configurações que uma aplicação precisa para rodar. Pense como uma mala de viagem organizada: tudo que o projeto precisa está dentro dela, e não importa em qual aeroporto (computador) você chegar — o conteúdo é o mesmo.
A diferença entre um container e uma máquina virtual (VM) é que a VM carrega um sistema operacional inteiro (como ter uma casa dentro de outra casa), enquanto o container compartilha o kernel do sistema host e empacota apenas o que é diferente — tornando-o muito mais leve e rápido para iniciar.
graph LR
subgraph "Máquina Virtual"
VM_OS["SO completo\n(Linux inteiro)"]
VM_APP["Aplicação"]
VM_LIB["Bibliotecas"]
VM_OS --- VM_APP --- VM_LIB
end
subgraph "Container"
C_APP["Aplicação"]
C_LIB["Bibliotecas\n(apenas o necessário)"]
C_APP --- C_LIB
end
HOST["Kernel do Host\n(compartilhado)"]
C_LIB -.-> HOST
VM_OS -.-> |"não compartilha"| HW["Hardware"]
style Container fill:#50b86c,stroke:#3a8a50,color:#fff
style HOST fill:#4a90d9,stroke:#2c5f8a,color:#fff
Neste projeto, o Docker Compose (uma ferramenta que orquestra múltiplos containers a partir de um arquivo de configuração) sobe três containers: a aplicação NestJS, o PostgreSQL e o RabbitMQ. O código-fonte da sua máquina é montado como volume dentro do container — isso significa que quando você edita um arquivo no seu editor (VS Code, WebStorm, etc.), a alteração aparece instantaneamente dentro do container, sem precisar reconstruí-lo. É como se o container tivesse uma "janela" apontando para a pasta do projeto na sua máquina.
graph TD
subgraph "Sua máquina (host)"
EDITOR["VS Code / WebStorm"]
SRC["Código-fonte\n./src/"]
end
subgraph "Container Docker"
VOL["Volume montado\n/ladesa/management-service/src/"]
BUN_RT["Bun runtime"]
NESTJS["NestJS App"]
end
SRC -- "bind mount\n(espelho em tempo real)" --> VOL
VOL --> BUN_RT --> NESTJS
EDITOR -- "edita" --> SRC
style EDITOR fill:#4a90d9,stroke:#2c5f8a,color:#fff
style VOL fill:#e8a838,stroke:#b07c1e,color:#fff
style NESTJS fill:#50b86c,stroke:#3a8a50,color:#fff
Para ir mais fundo: quando o Docker Compose declara
volumes: ['./src:/ladesa/management-service/src'], ele cria um bind mount — um mapeamento direto entre um diretório do host e um diretório dentro do container. Qualquer alteração em um lado reflete imediatamente no outro. Já o port forwarding (ex.:ports: ['3701:3701']) redireciona tráfego de rede da porta do host para a porta do container, permitindo que você acessehttp://localhost:3701no navegador e a requisição chegue ao NestJS rodando dentro do container. Os named volumes (ex.:management-service-db-data) persistem dados entre reinicializações do container — sem eles, o banco de dados seria zerado toda vez que o container parasse.
Para contribuir com este projeto, você precisa de:
| Opção | Instalação |
|---|---|
| Docker + Docker Compose (v2+) (recomendado) | docs.docker.com |
| Podman + Podman Compose | podman.io |
Nota sobre Podman: a recomendação oficial é o Docker. O projeto possui algumas configurações de compatibilidade com Podman (
userns_mode,x-podman), porém o uso do Podman é por conta e risco do usuário — podem haver problemas de compatibilidade não cobertos pelo projeto.Se optar pelo Podman, defina a variável de ambiente
OCI_RUNTIME=podmanantes de rodar os comandos.
O projeto usa o just como task runner no lugar do Make. A instalação é recomendada para quem pretende usar o Caminho A (justfile), que é o caminho principal de desenvolvimento.
| Plataforma | Instalação |
|---|---|
| Linux (curl) | curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin |
| macOS (Homebrew) | brew install just |
| Windows (Scoop) | scoop install just |
| Cargo | cargo install just |
Mais opções em: https://github.com/casey/just#installation
Necessário para clonar e versionar o código-fonte.
- Tutorial de instalação e configuração: https://docs.ladesa.com.br/docs/developers-guide/tutorials/source-code/git/
| Editor | Dev Container |
|---|---|
| VS Code | Suporte nativo via extensão Dev Containers |
| WebStorm | Suporte via Remote Development |
Você vai precisar usar o terminal para clonar o repositório, executar comandos e interagir com o container.
Com todas as ferramentas instaladas, o próximo passo é baixar o código-fonte do projeto para a sua máquina.
git clone https://github.com/ladesa-ro/management-service.git
cd management-serviceO
just setupjá copia automaticamente os arquivos.examplepara você. Nenhuma configuração manual é necessária para começar.
Existem dois caminhos para subir o ambiente de desenvolvimento. Escolha o que preferir:
| Caminho | Quando usar |
|---|---|
| A: justfile (recomendado) | Você gerencia os containers pelo terminal com o just, independentemente do editor. Funciona com qualquer editor ou IDE. |
| B: Dev Container | Você usa VS Code ou WebStorm e quer que o editor abra diretamente dentro do container, com extensões, terminal e tudo configurado automaticamente. |
O justfile oferece receitas prontas para gerenciar todo o ciclo de vida dos containers pelo terminal. É o caminho mais direto e flexível — funciona com qualquer editor.
just upEsse único comando faz tudo:
- Copia os arquivos
.enva partir dos exemplos (se ainda não existirem). - Faz o build das imagens dos containers (apenas se houve mudanças).
- Sobe os containers (aplicação + PostgreSQL + RabbitMQ).
- Instala as dependências (
bun install). - Abre um shell
zshdentro do container da aplicação.
Você já estará dentro do container após o just up. Basta rodar:
bun run dev| Comando | O que faz |
|---|---|
just up |
Sobe tudo e abre shell no container |
just start |
Sobe os containers em background (sem abrir shell) |
just stop |
Para os containers (sem remover) |
just down |
Para e remove os containers |
just cleanup |
Para, remove containers e volumes (reset completo — pede confirmação) |
just logs |
Mostra logs dos containers em tempo real |
just shell-1000 |
Abre shell como usuário happy (uid 1000) |
just shell-root |
Abre shell como root |
just build |
Faz o build da imagem (apenas se inputs mudaram — verifica hash) |
just rebuild |
Força rebuild da imagem |
just exec <args> |
Executa comando dentro do container |
just compose <args> |
Passa argumentos direto para o docker compose |
Usando Podman? Defina a variável
OCI_RUNTIME=podmanantes dos comandos:OCI_RUNTIME=podman just up
O Dev Container é uma alternativa que configura automaticamente todo o ambiente de desenvolvimento — extensões, formatação, terminal, portas — dentro do container Docker, integrado ao editor.
- Instale a extensão Dev Containers (
ms-vscode-remote.remote-containers). - Abra a pasta do projeto no VS Code.
- Quando aparecer a notificação "Reopen in Container", clique nela.
- Ou use o Command Palette (
Ctrl+Shift+P) e selecione Dev Containers: Reopen in Container.
- Ou use o Command Palette (
- Aguarde o build do container e a instalação das dependências (na primeira vez pode demorar alguns minutos).
- Abra o terminal integrado (
Ctrl+`) e inicie o servidor:
bun run dev- Abra a pasta do projeto no WebStorm.
- Vá em File > Remote Development > Dev Containers e selecione o
devcontainer.jsondo projeto. - Aguarde o build e a inicialização do container.
- Abra o terminal integrado e inicie o servidor:
bun run devExtensões pré-instaladas (21 extensões):
| Categoria | Extensões |
|---|---|
| TypeScript/JS | TypeScript Next, Biome (formatter/linter) |
| Runtime | Bun, JS Debug |
| Banco de dados | SQL Tools + Driver PostgreSQL |
| Docker | Docker, Remote Containers |
| Git | GitLens, Git Graph |
| API/GraphQL | GraphQL, OpenAPI (42Crunch) |
| Testes | Vitest Explorer |
| Utilidades | YAML, JSON, Path Intellisense, Spell Checker |
Configurações do editor:
- Formatador padrão: Biome — auto-format ao salvar.
- Terminal padrão:
zsh. - Imports: modo relativo (sem extensões).
Portas encaminhadas:
3701(API) —http://localhost:37019229(debug) — para attach do debugger5432(PostgreSQL) — para clientes SQL externos
Instalação automática: bun install executado no postCreateCommand.
Usuário do container: happy (uid 1000).
Ferramentas adicionais: Git (via PPA) e GitHub CLI instalados automaticamente.
Se você chegou até aqui, o projeto já está rodando na sua máquina. Agora vamos verificar que tudo funciona e fazer sua primeira interação com a API.
Após rodar just up (ou abrir o Dev Container) e iniciar o servidor com bun run dev, siga estes passos:
-
Aplique as migrações do banco de dados:
bun run migration:run
Isso cria todas as tabelas (58 migrações), funções/triggers e insere os dados iniciais (estados do Brasil, cidades de Rondônia, campus IFRO Ji-Paraná e superuser).
-
Acesse a documentação da API: Abra http://localhost:3701/api/docs no navegador. Você verá a documentação interativa Scalar/Swagger com todos os endpoints disponíveis.
-
Acesse o GraphQL Playground: Abra http://localhost:3701/api/graphql para explorar queries e mutations GraphQL.
-
Faça sua primeira requisição autenticada (mock): Em desenvolvimento, com
ENABLE_MOCK_ACCESS_TOKEN=true(padrão), você pode usar tokens simulados:# O token mock.matricula.1234 simula um usuário com matrícula 1234 curl -H "Authorization: Bearer mock.matricula.1234" http://localhost:3701/api/campi
-
Rode os testes para verificar que está tudo ok:
bun run test
Agora que você tem o projeto rodando e verificado, vamos explorar o que cada URL oferece e como interagir com a API.
Após iniciar o servidor com bun run dev, acesse:
| Recurso | URL | Descrição |
|---|---|---|
| Health check | http://localhost:3701/health | Verificação de saúde da aplicação (fora do prefixo) |
| Documentação Swagger/Scalar | http://localhost:3701/api/docs | Documentação interativa da API REST com Scalar |
| OpenAPI JSON | http://localhost:3701/api/docs/openapi.v3.json | Schema OpenAPI em JSON (para importação em Postman, Insomnia, etc.) |
| Swagger UI | http://localhost:3701/api/docs/swagger | Interface Swagger UI clássica |
| GraphQL Playground | http://localhost:3701/api/graphql | Interface GraphiQL para explorar e testar queries/mutations |
As URLs acima usam o prefixo padrão
/api/. Se oAPI_PREFIXfor alterado no.env, as URLs mudam de acordo. Veja Sobre o prefixo para detalhes.
A documentação da API REST é gerada automaticamente a partir dos decorators do NestJS no código-fonte. Ao acessar http://localhost:3701/api/docs, você encontra a interface Scalar — uma alternativa moderna ao Swagger UI:
O que você pode fazer na documentação:
- Explorar endpoints — todos os endpoints REST agrupados por módulo (tags
@ApiTags). - Testar requisições — enviar requests diretamente pelo navegador, com payload e autenticação.
- Ver schemas — tipos de entrada e saída de cada endpoint, com exemplos.
- Autenticar — clicar em "Authorize" e inserir o Bearer token (ex.:
mock.matricula.1234em desenvolvimento). - Exportar — baixar o schema OpenAPI em JSON para importar no Postman, Insomnia ou outra ferramenta.
Principais endpoints REST:
| Área | Path base | Métodos |
|---|---|---|
| Campi | /api/campi |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Blocos | /api/blocos |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Ambientes | /api/ambientes |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Turmas | /api/turmas |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id, GET /:id/horario |
| Diários | /api/diarios |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Cursos | /api/cursos |
GET /, GET /:id, POST /, PATCH /:id |
| Disciplinas | /api/disciplinas |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Modalidades | /api/modalidades |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Usuários | /api/usuarios |
GET /, GET /:id, POST /, PATCH /:id |
| Autenticação | /api/autenticacao |
GET /quem-sou-eu, POST /login, POST /login/refresh |
| Calendários letivos | /api/calendarios-letivos |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Horários de aula | /api/horarios-aula |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Empresas | /api/empresas |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Estágios | /api/estagios |
GET /, GET /:id, POST /, PATCH /:id, DELETE /:id |
| Estados | /api/base/estados |
GET /, GET /:id |
| Cidades | /api/base/cidades |
GET /, GET /:id |
| Arquivos | /api/arquivos |
GET /, POST / |
| Gerar horário | /api/gerar-horario |
POST /, GET /:id, POST /:id/aceitar, POST /:id/rejeitar |
Para entender de onde vêm todas essas URLs, é útil saber quais serviços rodam por trás do projeto. Quando você sobe o ambiente (via Dev Container ou just up), o Docker Compose inicia vários containers que trabalham juntos:
graph TB
subgraph Docker Compose
MS["Management Service\n:3701 (API)\n:9229 (debug)"]
DB["PostgreSQL 15\n(bitnamilegacy/postgresql:15)\n:5432"]
RMQ["RabbitMQ 3\n(rabbitmq:3-management-alpine)\n:15672 (UI)"]
end
MS --> DB
MS --> RMQ
style MS fill:#4a90d9,stroke:#2c5f8a,color:#fff
style DB fill:#336791,stroke:#1e3d5c,color:#fff
style RMQ fill:#ff6600,stroke:#b34700,color:#fff
| Serviço | Container | Porta | Credenciais |
|---|---|---|---|
| Management Service | ladesa-management-service |
3701 (API), 9229 (debug) |
— |
| PostgreSQL 15 | ladesa-management-service-db |
5432 |
database: main, password: 7f22682363b549a389e03b7fe512488b |
| RabbitMQ 3 | ladesa-rabbitmq |
5672 (AMQP), 15672 (UI) |
admin / admin |
Volumes persistentes:
management-service-db-data— dados do PostgreSQL (persistem entre restarts)management-service-uploaded-files— arquivos enviadosmanagement-service-shell-history— histórico do shell
Rede: ladesa-net (bridge — uma rede virtual interna do Docker que permite que os containers se encontrem pelo nome) — todos os serviços se comunicam por nome de container.
As variáveis são definidas no arquivo .env, criado automaticamente a partir do .env.example. A tabela abaixo lista todas as variáveis com seus valores padrão:
| Variável | Valor padrão | Descrição |
|---|---|---|
PORT |
3701 |
Porta da aplicação |
NODE_ENV |
development |
Ambiente de execução |
API_PREFIX |
/api/ |
Prefixo global de todas as rotas (REST, docs e GraphQL) |
| Variável | Valor padrão | Descrição |
|---|---|---|
DB_CONNECTION |
postgres |
Tipo de conexão |
DATABASE_URL |
postgresql://postgres:7f22...@ladesa-management-service-db:5432/main |
String de conexão completa com o PostgreSQL |
DATABASE_USE_SSL |
false |
Habilitar SSL na conexão com o banco |
TYPEORM_LOGGING |
true |
Logs de queries SQL no console (útil para debug, desabilitar em produção) |
| Variável | Valor padrão | Descrição |
|---|---|---|
OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER |
https://sso.ladesa.com.br/realms/sisgea-playground |
URL do issuer OIDC (usada para obter o JWKS endpoint) |
OAUTH2_CLIENT_REGISTRATION_LOGIN_CLIENT_ID |
luna-backend |
Client ID OAuth2 |
OAUTH2_CLIENT_REGISTRATION_LOGIN_CLIENT_SECRET |
8c9jOX... |
Client Secret OAuth2 |
OAUTH2_CLIENT_REGISTRATION_LOGIN_SCOPE |
openid profile |
Scopes OAuth2 solicitados |
| Variável | Valor padrão | Descrição |
|---|---|---|
KC_BASE_URL |
https://sso.ladesa.com.br |
URL base do Keycloak |
KC_REALM |
sisgea-playground |
Realm do Keycloak |
KC_CLIENT_ID |
luna-backend |
Client ID para operações administrativas |
KC_CLIENT_SECRET |
8c9jOX... |
Client Secret para admin client |
| Variável | Valor padrão | Descrição |
|---|---|---|
ENABLE_MOCK_ACCESS_TOKEN |
true |
Habilita tokens simulados no formato mock.matricula.<número>. Quando ativo, não é necessário Keycloak para autenticar. Deve ser false em produção. |
| Variável | Valor padrão | Descrição |
|---|---|---|
MESSAGE_BROKER_URL |
amqp://admin:admin@ladesa-rabbitmq |
URL de conexão AMQP com o RabbitMQ |
MESSAGE_BROKER_QUEUE_TIMETABLE_REQUEST |
dev.timetable_generate.request |
Fila para requisições de geração de horário |
MESSAGE_BROKER_QUEUE_TIMETABLE_RESPONSE |
dev.timetable_generate.response |
Fila para respostas de geração de horário |
| Variável | Valor padrão | Descrição |
|---|---|---|
STORAGE_PATH |
/container/uploaded |
Diretório onde arquivos enviados são armazenados |
O API_PREFIX define o prefixo global de todas as rotas da aplicação — REST, documentação e GraphQL. O valor padrão no .env.example é /api/.
Todas as URLs ficam sob esse prefixo:
| Rota | URL resultante com /api/ |
|---|---|
| Endpoints REST | http://localhost:3701/api/campi |
| Documentação Scalar | http://localhost:3701/api/docs |
| Swagger UI | http://localhost:3701/api/docs/swagger |
| OpenAPI JSON | http://localhost:3701/api/docs/openapi.v3.json |
| GraphQL | http://localhost:3701/api/graphql |
| Health check | http://localhost:3701/health (excluído do prefixo) |
Nota: o ambiente de produção/desenvolvimento público (
dev.ladesa.com.br) pode usar um prefixo diferente (ex.:/api/v1/), configurado via variável de ambiente no deploy. Localmente, o padrão é/api/.
Além de bun run dev, o projeto tem diversos scripts para tarefas comuns. Eles são sua caixa de ferramentas do dia a dia.
Todos os scripts são executados dentro do container com bun run <script>. Se você não estiver no shell do container (via just up), use just exec bun run <script>.
| Script | Descrição |
|---|---|
dev |
Inicia o servidor em modo de desenvolvimento (com watch/hot reload) |
start |
Inicia o servidor em modo de produção |
debug |
Inicia com debugger na porta 9229 (para attach do editor) |
| Script | Descrição |
|---|---|
code:fix |
Formata e corrige o código automaticamente (Biome) — obrigatório após alterações |
code:check |
Verifica formatação e linting sem alterar arquivos |
code:fix:format |
Apenas formata (sem lint fix) |
code:fix:lint |
Apenas corrige linting (sem format) |
code:check:format |
Apenas verifica formatação |
code:check:lint |
Apenas verifica linting |
typecheck |
Verifica tipagem TypeScript sem compilar — obrigatório após alterações |
modulecheck |
Valida as fronteiras entre módulos |
check |
Executa validação completa (typecheck + modulecheck + code:check) |
| Script | Descrição |
|---|---|
test |
Executa os testes unitários uma vez |
test:watch |
Executa os testes em modo watch (re-executa ao salvar) |
test:cov |
Executa os testes com relatório de cobertura (v8) |
test:e2e |
Executa os testes end-to-end (integração com banco e serviços) |
test:debug |
Executa os testes com debugger |
| Script | Descrição |
|---|---|
migration:run |
Aplica migrações pendentes no banco de dados |
migration:revert |
Reverte a última migração aplicada |
db:reset |
Reset completo do banco (drop + create + migrate + seed) |
typeorm |
Executa comandos TypeORM diretamente |
typeorm:create |
Cria um arquivo de migração vazio |
typeorm:entity |
Gera uma entidade TypeORM |
typeorm:generate |
Gera migração a partir do diff entre entidades e banco |
| Script | Descrição |
|---|---|
codegen:timetable-generator:fresh |
Gera tipos TypeScript para mensagens do timetable generator |
Até agora você já sabe rodar o projeto, acessar as URLs e executar scripts. Quando você rodou bun run migration:run nos primeiros passos, criou as tabelas no banco de dados. Mas como exatamente o projeto armazena e gerencia esses dados?
Esta seção explica os três conceitos fundamentais por trás da camada de dados: como objetos do código viram linhas no banco (ORM), como a exclusão de registros funciona (soft delete) e como múltiplas operações no banco se mantêm consistentes (transações ACID).
Um ORM é uma ferramenta que faz a ponte entre objetos do código e tabelas do banco de dados relacional. Em vez de escrever SQL manualmente (INSERT INTO campus (nome_fantasia, ...) VALUES (...)) você manipula objetos TypeScript e o ORM traduz para SQL.
graph LR
subgraph "Código TypeScript"
OBJ["Objeto Campus\n{\n id,\n nomeFantasia,\n cnpj\n}"]
end
ORM_ENGINE["ORM\n(TypeORM)"]
subgraph "Banco de dados"
TBL["Tabela campus\n| id | nome_fantasia | cnpj |"]
end
OBJ -- "salvar" --> ORM_ENGINE
ORM_ENGINE -- "INSERT INTO\ncampus (...)" --> TBL
TBL -- "SELECT *\nFROM campus" --> ORM_ENGINE
ORM_ENGINE -- "instancia\nobjeto" --> OBJ
style OBJ fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style ORM_ENGINE fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style TBL fill:#336791,stroke:#1e3d5c,color:#fff,text-align:left
Neste projeto, usamos o TypeORM v0.3.28. Cada entidade de domínio (como Campus) tem uma entidade TypeORM correspondente (como CampusEntity em src/modules/ambientes/campus/infrastructure.database/typeorm/campus.typeorm.entity.ts) que define como os campos são mapeados para colunas do banco. O mapeamento fica apenas na camada de infraestrutura — a entidade de domínio em domain/campus.ts não sabe que o TypeORM existe.
graph TD
subgraph "Domínio (não sabe do ORM)"
DOM_ENT["Campus\n(entidade de domínio)\ncampus.ts"]
end
subgraph "Infraestrutura (sabe do ORM)"
ORM_ENT["CampusEntity\n(entidade TypeORM)\ncampus.typeorm.entity.ts"]
REPO["CampusTypeormRepository\n(adapter)"]
end
subgraph "Banco"
DB_TBL["Tabela 'campus'\nPostgreSQL"]
end
DOM_ENT -- "contrato\n(interface)" --> REPO
REPO -- "mapeia para" --> ORM_ENT
ORM_ENT -- "persiste em" --> DB_TBL
style DOM_ENT fill:#e8a838,stroke:#b07c1e,color:#fff
style ORM_ENT fill:#50b86c,stroke:#3a8a50,color:#fff
style DB_TBL fill:#336791,stroke:#1e3d5c,color:#fff
// src/modules/ambientes/campus/infrastructure.database/typeorm/campus.typeorm.entity.ts
@Entity("campus")
export class CampusEntity {
@PrimaryColumn("uuid") id!: string;
@Column("text") nomeFantasia!: string;
@Column("text") razaoSocial!: string;
@ManyToOne(() => EnderecoEntity)
@JoinColumn({ name: "id_endereco_fk" })
endereco!: Relation<EnderecoEntity>;
// ...
}Para ir mais fundo: o projeto usa
synchronize: falseno TypeORM — isso significa que o ORM nunca altera a estrutura do banco automaticamente. Toda alteração no schema do banco é feita via migrações manuais (scripts SQL versionados). Essa decisão evita surpresas em produção, onde uma sincronização automática poderia apagar dados ou alterar colunas inesperadamente. O trade-off é que o desenvolvedor precisa criar migrações manualmente a cada mudança em entidades. O TypeORM oferecetypeorm:generatepara gerar a migração automaticamente a partir do diff entre o código e o banco atual.
Soft delete significa que quando você "exclui" um registro, ele não é removido fisicamente do banco — apenas recebe uma marcação de exclusão. É como jogar um arquivo na lixeira em vez de deletá-lo permanentemente: ele fica invisível para uso normal, mas pode ser recuperado se necessário.
graph LR
CREATE["Campus.create()"] --> ATIVO["Ativo\ndateDeleted = null\n\nAparece em findAll\ne findById"]
ATIVO -- "DELETE endpoint\ndateDeleted = NOW()" --> EXCLUIDO["Excluído\ndateDeleted = 2026-03-22T...\n\nNão aparece em consultas"]
EXCLUIDO -- "Restaurar\ndateDeleted = null" --> ATIVO
UPDATE["campus.update()"] --> ATIVO
style ATIVO fill:#50b86c,stroke:#3a8a50,color:#fff
style EXCLUIDO fill:#e74c3c,stroke:#c0392b,color:#fff
style CREATE fill:#4a90d9,stroke:#2c5f8a,color:#fff
style UPDATE fill:#e8a838,stroke:#b07c1e,color:#fff
Neste projeto, toda entidade tem um campo dateDeleted (do tipo timestamptz, nullable). Quando é null, o registro está ativo. Quando preenchido com uma data, o registro é considerado excluído. Queries de listagem filtram automaticamente registros com dateDeleted IS NOT NULL.
Para ir mais fundo: o banco possui triggers que gerenciam datas automaticamente. A function
change_date_updated()(criada na migração1742515200000) é executada como triggerBEFORE UPDATEem cada tabela, atualizando o campodate_updatedautomaticamente. A stored procedureensure_change_date_trigger(table_name)(migração1742515260000) é chamada durante a criação de cada tabela para anexar esse trigger. Isso garante quedate_updatedé sempre preciso, independentemente de o código da aplicação se lembrar de atualizá-lo. O código emsrc/infrastructure.database/migrations/1742515200000-create-function-change-date-updated.tsdefine essa function PostgreSQL.
Uma transação agrupa várias operações no banco de dados em uma unidade atômica — ou todas acontecem, ou nenhuma. É como uma transferência bancária: se o débito funciona mas o crédito falha, ambos são revertidos automaticamente.
ACID são as quatro garantias de uma transação:
- Atomicidade — tudo ou nada.
- Consistência — o banco nunca fica em estado inválido.
- Isolamento — transações paralelas não se atrapalham.
- Durabilidade — depois do commit, o dado sobrevive a quedas.
graph TD
subgraph "Transação (ACID)"
OP1["INSERT campus"]
OP2["INSERT endereco"]
OP3["UPDATE perfil"]
end
OP1 --> OP2 --> OP3
OP3 --> |"tudo OK"| COMMIT["COMMIT\n(todas as operações\npersistidas)"]
OP2 -.-> |"erro no meio"| ROLLBACK["ROLLBACK\n(nenhuma operação\npersistida — tudo volta\nao estado anterior)"]
style COMMIT fill:#50b86c,stroke:#3a8a50,color:#fff
style ROLLBACK fill:#e74c3c,stroke:#c0392b,color:#fff
Neste projeto, as transações são automáticas. O TransactionInterceptor (em src/server/nest/interceptors/transaction.interceptor.ts) abre uma transação antes de cada handler. Se o handler completa sem erro → COMMIT. Se lança exceção → ROLLBACK. Como desenvolvedor, você nunca precisa chamar .transaction() manualmente.
sequenceDiagram
participant REQ as Requisição HTTP
participant TI as TransactionInterceptor
participant ALS as AsyncLocalStorage
participant H as Handler
participant R as Repositório
participant DB as PostgreSQL
REQ->>TI: chega requisição
TI->>DB: BEGIN TRANSACTION
TI->>ALS: armazena EntityManager transacional
TI->>H: executa handler
H->>R: repository.create(campus)
R->>ALS: getActiveEntityManager()
ALS-->>R: EntityManager (transacional)
R->>DB: INSERT INTO campus (via EntityManager)
DB-->>R: OK
alt Sucesso
H-->>TI: resultado
TI->>DB: COMMIT
TI-->>REQ: 201 Created
else Exceção
H-->>TI: ForbiddenError
TI->>DB: ROLLBACK
TI-->>REQ: 403 Forbidden
end
// src/server/nest/interceptors/transaction.interceptor.ts (código real)
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return from(
this.appTypeormConnection.transaction((entityManager) => {
return transactionStorage.run(entityManager, () => {
return new Promise<unknown>((resolve, reject) => {
next.handle().subscribe({ next: resolve, error: reject });
});
});
}),
);
}
}Para ir mais fundo: o mecanismo usa
AsyncLocalStorage(Node.js) para propagar oEntityManagertransacional por toda a call stack da requisição, sem passá-lo explicitamente. OAppTypeormConnectionProxy(emsrc/infrastructure.database/typeorm/connection/app-typeorm-connection.proxy.ts) intercepta chamadas agetRepository()— se existe umEntityManagerativo noAsyncLocalStorage, usa-o (participando da transação); caso contrário, usa oDataSourceglobal. Esse padrão é uma variação do Unit of Work — todos os repositórios dentro de uma requisição compartilham a mesma transação sem saber disso. O trade-off: transação por requisição é simples mas pode manter locks por mais tempo em handlers lentos — por isso handlers devem ser rápidos e focados.
Migrações são scripts que alteram a estrutura do banco de dados de forma versionada e reproduzível. Pense como um "Git para o banco de dados": cada alteração é registrada em um arquivo timestamped, pode ser aplicada (up) ou revertida (down), e o banco sabe quais migrações já foram executadas.
O projeto usa TypeORM com migrações manuais (synchronize: false — o banco nunca é alterado automaticamente). As migrações ficam em src/infrastructure.database/migrations/ e são nomeadas com timestamp (ex.: 1742515200000-create-function-change-date-updated.ts).
Atualmente o projeto possui 58 migrações organizadas em categorias:
| Categoria | Quantidade | Exemplos |
|---|---|---|
| Funções e procedures | 2 | change_date_updated(), ensure_change_date_trigger() |
| Tabelas de referência | 2 | base_estado, base_cidade |
| Tabelas de infraestrutura | 3 | endereco, arquivo, imagem |
| Tabelas de acesso | 3 | usuario, perfil, notificacao |
| Tabelas de ambientes | 3 | campus, bloco, ambiente |
| Tabelas de ensino | 15 | modalidade, curso, disciplina, turma, diario, etc. |
| Tabelas de horários | 18 | horario_aula, calendario_letivo, gerar_horario, etc. |
| Tabelas de estágio | 5 | empresa, estagiario, estagio, etc. |
| Dados seed | 4 | Estados do Brasil, cidades de Rondônia, campus IFRO, superuser |
| Correções | 1 | Colunas e triggers faltantes |
Comandos:
# Aplicar migrações pendentes (primeira vez ou após pull)
bun run migration:run
# Reverter a última migração
bun run migration:revert
# Gerar uma nova migração a partir de alterações nas entidades TypeORM
bun run typeorm:generate
# Reset completo — apaga tudo e recria (cuidado: perde todos os dados!)
bun run db:resetgraph LR
A["Alterar entidade TypeORM\n(*.typeorm.entity.ts)"] --> B["bun run typeorm:generate\n(gera migração)"]
B --> C["Revisar migração\n(em migrations/)"]
C --> D["bun run migration:run\n(aplica no banco)"]
D --> E["bun run typecheck\n(verificar tipos)"]
style A fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style B text-align:left
style C text-align:left
style D fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style E text-align:left
- Altere a entidade TypeORM em
infrastructure.database/typeorm/. - Gere a migração:
bun run typeorm:generate. - Revise o arquivo gerado em
src/infrastructure.database/migrations/. - Aplique:
bun run migration:run.
O banco já vem com dados de seed inseridos via migração — por exemplo, todos os estados do Brasil com códigos IBGE, cidades de Rondônia, o campus do IFRO Ji-Paraná e um superuser. Esses dados são inseridos automaticamente ao rodar migration:run pela primeira vez.
As entidades usam exclusão lógica (soft delete) — registros nunca são removidos fisicamente do banco. Em vez disso, o campo dateDeleted é preenchido com a data da exclusão.
sequenceDiagram
participant APP as Aplicação
participant DB as PostgreSQL
participant TRIGGER as Trigger change_date_updated
Note over APP,DB: CREATE
APP->>DB: INSERT INTO campus (id, nome_fantasia, date_created, date_updated, date_deleted)\nVALUES ('uuid', 'IFRO', NOW(), NOW(), NULL)
Note over APP,DB: UPDATE
APP->>DB: UPDATE campus SET nome_fantasia = 'IFRO JPA' WHERE id = 'uuid'
DB->>TRIGGER: BEFORE UPDATE (automático)
TRIGGER->>DB: SET date_updated = NOW()
Note over APP,DB: SOFT DELETE
APP->>DB: UPDATE campus SET date_deleted = NOW() WHERE id = 'uuid'
DB->>TRIGGER: BEFORE UPDATE (automático)
TRIGGER->>DB: SET date_updated = NOW()
Note over DB: Registro marcado como excluído\nmas ainda existe no banco
Note over APP,DB: LISTAGEM (filtra excluídos)
APP->>DB: SELECT * FROM campus WHERE date_deleted IS NULL
O banco possui triggers automáticos para controle de datas:
- Function
change_date_updated()— trigger function que executanew.date_updated := now()antes de cada UPDATE. - Procedure
ensure_change_date_trigger(table_name)— cria o trigger automaticamente em qualquer tabela. É chamada durante a criação de cada tabela nas migrações:
-- Chamada no final de cada migração de tabela:
CALL ensure_change_date_trigger('campus');Isso garante que date_updated é sempre preciso, independentemente de a aplicação se lembrar de atualizá-lo.
Com o banco de dados entendido, a próxima pergunta é: como a API sabe quem está fazendo uma requisição e se essa pessoa tem permissão para fazer o que está pedindo? A resposta envolve três conceitos que trabalham juntos: JWT (o "crachá digital" do usuário), JWKS (como a API verifica se o crachá é legítimo) e OAuth2/OIDC (o fluxo de login completo).
Para entender o fluxo de autenticação deste projeto, é importante conhecer os conceitos de JWT, JWKS e OAuth2/OIDC.
Um JWT é um token (uma string codificada) que carrega informações sobre um usuário. É como um crachá digital: contém quem você é (claims), quem emitiu (issuer) e uma assinatura que prova que ninguém adulterou o conteúdo. O token é composto de três partes separadas por pontos: header.payload.signature.
graph LR
subgraph "JWT (3 partes separadas por '.')"
H["Header\n{\n alg: RS256,\n typ: JWT,\n kid: 'abc123'\n}"]
P["Payload (claims)\n{\n sub: 'user-id',\n matricula: '1234',\n exp: 1711000000\n}"]
S["Signature\nHMAC(\n header + payload,\n chave secreta\n)"]
end
H --- P --- S
style H fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style P fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style S fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
A grande vantagem do JWT é que a API não precisa consultar o banco de dados para verificar se o token é válido — basta verificar a assinatura usando a chave pública do emissor.
sequenceDiagram
participant KC as Keycloak
participant API as Management Service
participant DB as PostgreSQL
Note over KC: Possui chave privada
Note over API: Possui chave pública (via JWKS)
KC->>KC: Assina JWT com chave privada
KC-->>API: JWT assinado
API->>API: Verifica assinatura com chave pública
Note over API: Não precisa consultar o banco!
API->>API: Extrai claims (matrícula, etc.)
Neste projeto, JWTs são emitidos pelo Keycloak (o servidor de autenticação). Quando um cliente faz login no Keycloak, recebe um JWT assinado com a chave privada do Keycloak. A API valida esse JWT usando a chave pública correspondente, obtida via JWKS (veja abaixo).
Para ir mais fundo: JWTs são auto-contidos — toda a informação necessária para validação está no próprio token. Isso os diferencia de tokens opacos (como session IDs), que são apenas referências e exigem uma consulta ao servidor emissor para validação. O trade-off é que JWTs não podem ser "revogados" instantaneamente — uma vez emitido, ele é válido até expirar (campo
exp). Por isso JWTs costumam ter validade curta (minutos), e um refresh token é usado para obter novos access tokens sem exigir novo login.
JWKS é um endpoint HTTP que expõe as chaves públicas usadas para verificar assinaturas de JWTs. Em vez de configurar a chave pública manualmente na API, a API consulta o endpoint JWKS do Keycloak e obtém as chaves atuais automaticamente.
sequenceDiagram
participant API as Management Service
participant KC as Keycloak
API->>KC: GET /.well-known/openid-configuration
KC-->>API: {jwks_uri: ".../certs"}
API->>KC: GET /realms/sisgea/protocol/openid-connect/certs
KC-->>API: {keys: [{kid: "abc", kty: "RSA", n: "...", e: "..."}]}
Note over API: Cacheia as chaves\n(5 chaves max, 10min TTL)
API->>API: Usa chave pública para\nvalidar assinatura do JWT
Neste projeto, a API busca o JWKS do Keycloak na URL {OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER}/.well-known/openid-configuration para descobrir o endpoint de chaves. A implementação fica em src/infrastructure.identity-provider/jwks/. A biblioteca jwks-rsa v4 cuida de buscar e cachear as chaves.
Para ir mais fundo: o JWKS permite rotação de chaves sem downtime — o Keycloak pode gerar um novo par de chaves e começar a assinar tokens com a nova chave, enquanto a antiga ainda aparece no JWKS para validar tokens já emitidos. O campo
kid(Key ID) no header do JWT indica qual chave foi usada para assinar. A bibliotecajwks-rsafaz cache das chaves e as recarrega periodicamente ou quando encontra umkiddesconhecido.
OAuth2 é um protocolo de autenticação delegada — em vez do usuário informar sua senha diretamente à API, ele se autentica em um provedor confiável (como o Keycloak) que emite um token de acesso. É como quando você usa "Login com Google" em um site: você se autentica no Google, e o Google diz ao site "sim, este usuário é quem diz ser".
OIDC (OpenID Connect) é uma camada sobre o OAuth2 que adiciona informações padronizadas sobre o usuário (como nome, e-mail) via um ID Token.
sequenceDiagram
participant U as Usuário
participant FE as Front-end
participant KC as Keycloak (IdP)
participant API as Management Service
U->>FE: Clica em "Login"
FE->>KC: Redireciona para /auth (Authorization Code + PKCE)
U->>KC: Insere login e senha
KC->>KC: Valida credenciais
KC-->>FE: Redireciona de volta com authorization code
FE->>KC: Troca code por tokens (POST /token)
KC-->>FE: Access token (JWT) + Refresh token
Note over FE: Armazena tokens
loop Cada requisição à API
FE->>API: GET /api/campi + Authorization: Bearer <JWT>
API->>API: Valida JWT via JWKS
API-->>FE: Dados solicitados
end
Note over FE: Quando access token expira:
FE->>KC: POST /token (grant_type=refresh_token)
KC-->>FE: Novo access token
Neste projeto, o Keycloak é o Identity Provider (IdP — o servidor que gerencia contas de usuário e login). O fluxo é: (1) o front-end redireciona o usuário para o Keycloak, (2) o usuário faz login, (3) o Keycloak emite um JWT e redireciona de volta, (4) o front-end envia esse JWT em todas as requisições à API no header Authorization: Bearer <token> (o Bearer token é simplesmente a maneira padrão de enviar o JWT numa requisição HTTP — você coloca Bearer seguido do token no cabeçalho Authorization). A implementação fica em src/infrastructure.identity-provider/.
Para ir mais fundo: o OAuth2 define vários fluxos (grant types). Para SPAs e apps web, o Authorization Code (com PKCE) é o mais seguro — o client troca um código temporário por tokens, evitando que tokens apareçam na URL. O Client Credentials é usado para comunicação entre serviços (machine-to-machine). Neste projeto, o Management Service é um Resource Server — ele valida tokens mas não os emite. As credenciais de client (
KC_CLIENT_ID,KC_CLIENT_SECRET) são usadas pelo admin client do Keycloak para operações administrativas (como criar usuários).
A aplicação delega autenticação a um servidor Keycloak via protocolo OAuth2/OIDC:
sequenceDiagram
participant Cliente
participant API as Management Service
participant KC as Keycloak
participant DB as PostgreSQL
Cliente->>API: Requisição com Bearer token
API->>API: É mock token? (mock.matricula.*)
alt Token mock (dev)
API->>API: Extrai matrícula do token
else Token real (produção)
API->>KC: Obtém JWKS (chaves públicas)
KC-->>API: Chaves públicas (JSON Web Key Set)
API->>API: Valida assinatura do JWT
API->>API: Extrai claims do usuário
end
API->>DB: Busca Usuario por matrícula
DB-->>API: Dados do usuário
API->>API: Monta RequestActor (id, nome, matricula, email, isSuperUser)
API-->>Cliente: Resposta da API
Fluxo de autenticação (código real em src/server/nest/auth/request-actor-resolver.adapter.ts):
- O cliente envia um Bearer token no header
Authorization. - Se
ENABLE_MOCK_ACCESS_TOKEN=truee o token segue o formatomock.matricula.<número>:- A matrícula é extraída diretamente do token.
- Caso contrário, o token é validado via JWKS obtido do Keycloak.
- A API busca o
Usuariono banco pela matrícula. - Se o usuário existe, um
RequestActorcomid,nome,matricula,emaileisSuperUseré injetado nos controllers. - Se o usuário não existe no banco, retorna
ForbiddenException.
Tokens mock em desenvolvimento:
# O token mock.matricula.1234 simula um usuário com matrícula 1234
curl -H "Authorization: Bearer mock.matricula.1234" \
http://localhost:3701/api/campi
# Funciona com qualquer matrícula — basta mudar o número
curl -H "Authorization: Bearer mock.matricula.5678" \
http://localhost:3701/api/turmasEm produção,
ENABLE_MOCK_ACCESS_TOKENdeve serfalse. Tokens reais são emitidos pelo Keycloak e validados via JWKS.
Após a autenticação, cada módulo verifica se o usuário tem permissão para realizar a operação solicitada:
graph TD
REQ["Requisição autenticada\n(RequestActor disponível)"]
REQ --> CTRL["Controller / Resolver"]
CTRL --> HANDLER["Command Handler"]
HANDLER --> PC["PermissionChecker\ndo módulo"]
PC --> |"CREATE"| CAN_C["ensureCanCreate(ac, {dto})"]
PC --> |"UPDATE"| CAN_U["ensureCanUpdate(ac, {dto}, id)"]
PC --> |"DELETE"| CAN_D["ensureCanDelete(ac, {dto}, id)"]
CAN_C & CAN_U & CAN_D --> |"OK"| CONTINUE["Continua execução"]
CAN_C & CAN_U & CAN_D -.-> |"throw ForbiddenError"| DENIED["403 Forbidden"]
HANDLER2["Query Handler\n(leitura)"] --> |"accessContext pode\nser null (hoje público;\nroadmap: filtrar por permissão)"| REPO["Repositório"]
style REQ fill:#4a90d9,stroke:#2c5f8a,color:#fff
style CONTINUE fill:#50b86c,stroke:#3a8a50,color:#fff
style DENIED fill:#e74c3c,stroke:#c0392b,color:#fff
Isso é feito por um IPermissionChecker específico do módulo, com métodos:
ensureCanCreate(accessContext, { dto })— verifica se o usuário pode criar.ensureCanUpdate(accessContext, { dto }, id)— verifica se o usuário pode atualizar.ensureCanDelete(accessContext, { dto }, id)— verifica se o usuário pode excluir.
O padrão é "throw on deny": se o usuário não tiver permissão, uma exceção ForbiddenError (HTTP 403) é lançada automaticamente, e a operação é abortada.
Operações de leitura (queries) atualmente aceitam acesso com ou sem autenticação — o accessContext pode ser null. No roadmap está prevista a filtragem de resultados por permissão: o usuário verá apenas os registros que tem autorização para acessar.
Antes de contribuir com código, é essencial entender as regras de qualidade que o projeto segue. Toda alteração precisa passar por validação automática e formatação — o projeto não aceita código fora desses padrões.
Para entender como o projeto garante a integridade dos dados em todas as camadas, é importante conhecer o Zod.
Zod é uma biblioteca TypeScript que permite definir a "forma" que os dados devem ter e rejeitar automaticamente dados inválidos. Pense como um molde de bolo: se a massa não encaixa no molde, ela é rejeitada antes de entrar no forno.
A particularidade do Zod é que ele funciona tanto em compile-time (gerando tipos TypeScript via z.infer<typeof Schema>) quanto em runtime (validando dados reais com schema.safeParse(data)).
graph TD
ZOD_SCHEMA["Schema Zod\nz.object({\n nomeFantasia:\n z.string().min(1)\n})"]
ZOD_SCHEMA --> COMPILE["Compile-time\nz.infer gera tipo TypeScript\nICampus = {\n nomeFantasia: string\n}"]
ZOD_SCHEMA --> RUNTIME["Runtime\nschema.safeParse(dados)\nvalida dados reais"]
RUNTIME --> OK["Válido\n→ retorna dados tipados"]
RUNTIME --> ERR["Inválido\n→ retorna ZodError\ncom detalhes por campo"]
style ZOD_SCHEMA fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style COMPILE fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style RUNTIME fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style OK text-align:left
style ERR fill:#e74c3c,stroke:#c0392b,color:#fff,text-align:left
Neste projeto, Zod é o único sistema de validação — class-validator e class-transformer não são usados. A validação acontece em duas camadas: na apresentação (DTOs com static schema) e no domínio (factory methods das entidades). Os schemas ficam em src/modules/*/domain/*.schemas.ts.
graph LR
REQ["Requisição\n{ nomeFantasia: '' }"]
subgraph "Camada 1 — Apresentação"
PIPE["ZodGlobalValidationPipe\nusa DTO.schema"]
end
subgraph "Camada 2 — Domínio"
FACTORY["Campus.create()\nzodValidate(\n CampusCreateSchema\n)"]
end
REQ --> PIPE
PIPE -- "válido" --> FACTORY
PIPE -. "inválido\n400 Bad Request" .-> RESP1["❌ Erro de validação\n(detalhes por campo)"]
FACTORY -- "válido" --> OK["✅ Entidade criada"]
FACTORY -. "inválido\n(rede de segurança)" .-> RESP2["❌ Erro de domínio"]
style REQ text-align:left
style PIPE fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style FACTORY fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style RESP1 fill:#e74c3c,stroke:#c0392b,color:#fff,text-align:left
style RESP2 fill:#e74c3c,stroke:#c0392b,color:#fff,text-align:left
// src/modules/ambientes/campus/domain/campus.schemas.ts (trecho)
export const CampusCreateSchema = z.object({
nomeFantasia: CampusFields.nomeFantasia.schema,
razaoSocial: CampusFields.razaoSocial.schema,
apelido: CampusFields.apelido.schema,
cnpj: CampusFields.cnpj.schema,
endereco: CampusEnderecoRefSchema,
});Para ir mais fundo: a validação em duas camadas é intencional. A camada de apresentação (
ZodGlobalValidationPipe) rejeita dados malformados antes que cheguem ao handler — retornando 400 com detalhes por campo. A camada de domínio (zodValidate()nos factory methods) atua como rede de segurança — se por algum motivo dados inválidos chegarem ao domínio (ex.: chamada direta ao handler sem passar pelo pipe), a entidade rejeita. O tipoICampus = z.infer<typeof CampusSchema>garante type safety: o TypeScript sabe exatamente quais campos existem e seus tipos, derivados automaticamente do schema Zod.
Após qualquer alteração de código, execute estes dois comandos nesta ordem:
# 1. Formata e corrige linting automaticamente
bun run code:fix
# 2. Verifica que nenhum tipo está quebrado
bun run typecheckAmbos devem passar sem erros. Uma alteração não está concluída sem esses dois passos.
O projeto usa o Biome v2.4 como formatador e linter único:
| Regra | Configuração |
|---|---|
| Largura de linha | 100 caracteres |
| Indentação | 2 espaços |
| Ponto e vírgula | sempre |
| Trailing commas | todas |
| Imports não utilizados | removidos automaticamente |
| Variáveis não usadas | sinalizadas como erro |
const |
obrigatório quando possível |
| Organização de imports | automática |
| Line ending | LF |
| Bracket spacing | habilitado |
| Arrow parens | sempre |
# Corrigir formatação e linting
bun run code:fix
# Apenas verificar (sem alterar arquivos)
bun run code:checkO Dev Container já configura o Biome como formatador padrão com auto-format ao salvar — ou seja, ao salvar um arquivo no VS Code, ele é formatado automaticamente.
Com o projeto rodando, as ferramentas entendidas e as regras de qualidade claras, você está pronto para contribuir. Esta seção guia você desde os conceitos básicos de Git (se nunca usou) até abrir seu primeiro Pull Request.
Se você já conhece Git, pule para o Gitflow do projeto.
| Conceito | O que é |
|---|---|
| Repositório (repo) | A pasta do projeto com todo o histórico de alterações. Existe uma cópia remota (no GitHub) e uma local (na sua máquina). |
| Branch | Uma "ramificação" do código. Permite trabalhar em uma alteração sem afetar o código principal. Pense como uma cópia paralela onde você faz suas mudanças. |
| Commit | Um "ponto de salvamento" no histórico. Registra o que mudou, quem mudou e uma mensagem descrevendo a alteração. |
| Push | Envia seus commits locais para o repositório remoto (GitHub), tornando-os visíveis para o time. |
| Fetch | Baixa as referências e objetos do repositório remoto sem alterar nenhum arquivo local. Diferente de pull, que baixa e incorpora automaticamente. |
| Merge | O ato de juntar as alterações de uma branch na outra. Acontece quando um PR é aprovado ou quando você incorpora mudanças da main. |
| Pull Request (PR) | Uma solicitação para incorporar suas alterações (da sua branch) na branch principal (main). Outros devs revisam antes de aprovar. |
| Conflito | Quando duas pessoas alteraram a mesma parte do código. Precisa ser resolvido manualmente antes do merge. |
O projeto usa uma estratégia simples: branch única main + feature branches + merge via Pull Request.
gitGraph
commit id: "estado atual"
branch feat/cadastro-turma
commit id: "criar entidade"
commit id: "adicionar handler"
commit id: "code:fix + typecheck"
checkout main
branch fix/corrigir-paginacao
commit id: "corrigir offset"
checkout main
merge fix/corrigir-paginacao id: "PR #42 merged"
checkout feat/cadastro-turma
commit id: "adicionar testes"
checkout main
merge feat/cadastro-turma id: "PR #43 merged"
commit id: "próximo ciclo..."
Como funciona:
- A branch
mainé a versão estável do projeto. Todo código nela deve estar funcionando. - Para cada alteração, você cria uma feature branch a partir da
main. - Trabalha na feature branch (commits, testes, formatação).
- Quando terminar, abre um Pull Request para a
main. - Após revisão e aprovação, o PR é mergeado na
main.
O nome da branch indica o tipo da alteração:
| Prefixo | Quando usar | Exemplo |
|---|---|---|
feat/ |
Nova funcionalidade | feat/cadastro-estagio |
fix/ |
Correção de bug | fix/paginacao-campus |
refactor/ |
Refatoração sem mudança de comportamento | refactor/extrair-handler-turma |
docs/ |
Alteração apenas em documentação | docs/atualizar-readme |
test/ |
Adição ou correção de testes | test/handler-diario |
chore/ |
Tarefas de manutenção (deps, CI, config) | chore/atualizar-nestjs |
Commits seguem o padrão Conventional Commits:
tipo(escopo): descrição curta do que foi feito
| Parte | Descrição | Exemplo |
|---|---|---|
| tipo | Categoria da mudança | feat, fix, refactor, docs, test, chore |
| escopo | Módulo ou área afetada (opcional) | campus, turma, auth, database |
| descrição | O que foi feito, em imperativo | adicionar endpoint de listagem |
Exemplos bons vs ruins:
| Bom | Ruim |
|---|---|
feat(campus): adicionar endpoint de criação |
update |
fix(turma): corrigir paginação na listagem |
fix bug |
refactor(auth): extrair validação de token |
refatoração |
docs: atualizar variáveis de ambiente no README |
docs |
test(diario): adicionar testes do create handler |
add tests |
Manter a branch local sincronizada é fundamental para evitar conflitos. O fluxo recomendado neste projeto usa git fetch -p + git merge origin/main em vez de git pull.
git pull é um atalho que faz git fetch + git merge (ou rebase, dependendo da config global) automaticamente. Isso pode causar problemas:
- Se o dev tem
pull.rebase = truena config global e fazgit pull origin mainna branch de feature, os commits locais são rebaseados sobre a main — reescrevendo o histórico da feature branch. Se ele já tinha dado push, isso causa divergência. - Separar
fetchemergeé mais explícito e seguro: você vê o que mudou antes de incorporar.
graph TD
A["Início do trabalho"] --> B["git fetch -p"]
B --> C["git merge origin/main"]
C --> D{Conflitos?}
D -- Não --> E["Continua trabalhando"]
D -- Sim --> F["Resolve conflitos"]
F --> G["git add arquivos-resolvidos"]
G --> H["git commit"]
H --> E
style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
style D fill:#e8a838,stroke:#b07c1e,color:#fff
style E fill:#50b86c,stroke:#3a8a50,color:#fff
Explicação de cada comando:
-
git fetch -p— baixa as referências e objetos do repositório remoto sem alterar nenhum arquivo local. O-p(prune) limpa referências locais de branches remotas que já foram deletadas no GitHub. Após o fetch,origin/mainaponta para o commit mais recente da main no GitHub, mas sua branch local não muda. -
git merge origin/main— incorpora as mudanças deorigin/mainna branch onde você está (sua feature branch). Isso é feito sem trocar para amainlocal — você referencia diretamenteorigin/main. Se não houver conflitos, o merge acontece automaticamente.
Neste projeto, a convenção é:
- Nunca faça checkout na
mainlocal para atualizar. Use sempreorigin/maincomo referência. - A
mainlocal pode ficar desatualizada e isso é OK — ela não é usada para nada. - Se a
mainlocal ficou divergente ou confusa:git checkout main && git reset --hard origin/main(após um fetch) — isso faz a branch local apontar exatamente para o mesmo commit deorigin/main, descartando qualquer divergência local.
# Início do trabalho (na sua feature branch):
git fetch -p
git merge origin/main
# Fim do trabalho:
bun run code:fix
bun run typecheck
git add .
git commit -m "feat(modulo): descrição"
git push origin feat/minha-feature
# Criando nova branch (a partir do remoto atualizado):
git fetch -p
git checkout -b feat/nova-feature origin/mainO git checkout -b feat/nova-feature origin/main cria uma nova branch a partir de origin/main (a versão mais recente da main no GitHub) — melhor que criar a partir da main local, que pode estar desatualizada.
- O Git marca os conflitos nos arquivos com
<<<<<<<,=======,>>>>>>>. - Abra cada arquivo conflitante e escolha qual versão manter (ou combine ambas).
- Remova os marcadores de conflito.
- Adicione e commite:
git add . git commit -m "merge: resolver conflitos com main"
Dica: use o editor (VS Code tem uma interface visual para resolver conflitos) em vez de editar manualmente.
Todo o desenvolvimento acontece dentro do container Docker. Isso garante que todos usam as mesmas versões de ferramentas.
graph TD
subgraph "Sua máquina (host)"
EDITOR["Editor de código\n(VS Code, WebStorm, etc.)"]
JUST["just (task runner)"]
end
subgraph "Container Docker"
BUN["Bun (runtime)"]
APP["Aplicação NestJS"]
TOOLS["Ferramentas\n(TypeScript, Biome, Vitest)"]
end
subgraph "Containers de serviço"
DB["PostgreSQL 15"]
RMQ["RabbitMQ 3"]
end
EDITOR -- "edita arquivos\n(volume montado)" --> APP
JUST -- "just exec / just up" --> BUN
BUN --> APP
BUN --> TOOLS
APP --> DB
APP --> RMQ
style EDITOR fill:#4a90d9,stroke:#2c5f8a,color:#fff
style BUN fill:#e8a838,stroke:#b07c1e,color:#fff
style DB fill:#336791,stroke:#1e3d5c,color:#fff
# 1. Suba o ambiente (se ainda não estiver rodando)
just up # Sobe containers e abre shell
# 2. Dentro do container, inicie o servidor
bun run dev # Servidor com hot reload
# 3. Em outro terminal, rode comandos conforme necessário
just exec bun run test # Testes
just exec bun run code:fix # Formatação
just exec bun run typecheck # Verificação de tipos
just exec bun run migration:run # MigraçõesO código fica na sua máquina e é montado como volume dentro do container. Isso significa:
- Você edita no editor normalmente (VS Code, WebStorm, Vim, etc.).
- As alterações aparecem instantaneamente dentro do container (sem rebuild).
- O
bun run devdetecta as mudanças e faz hot reload automaticamente. - Para rodar comandos (testes, lint, migrações), use
just execou o shell dentro do container.
- Dois terminais: um para o servidor (
bun run dev), outro para comandos (just exec ...). - Hot reload: salve o arquivo e veja as mudanças refletidas automaticamente no servidor.
- Debug: use
bun run debuge conecte o debugger do editor na porta9229. - Logs: se algo não funcionar, veja os logs com
just logs.
sequenceDiagram
participant Dev as Desenvolvedor
participant Local as Git Local
participant Container as Container Docker
participant Remote as GitHub
participant Team as Time (Review)
Dev->>Local: git checkout -b feat/minha-feature origin/main
Note over Dev: Faz alterações no código
Dev->>Container: bun run code:fix
Container-->>Dev: Código formatado
Dev->>Container: bun run typecheck
Container-->>Dev: Tipos OK
Dev->>Container: bun run test
Container-->>Dev: Testes passando
Dev->>Local: git add + git commit
Dev->>Remote: git push origin feat/minha-feature
Dev->>Remote: Abre Pull Request
Remote->>Team: Notifica revisores
Team->>Remote: Revisa e aprova
Remote->>Remote: Merge na main
Note over Remote: CI/CD deploya automaticamente
git fetch -p # Atualiza referências
git checkout -b feat/minha-feature origin/main # Cria branch a partir do remotoEdite o código seguindo a estrutura de módulos e as boas práticas.
bun run code:fix # Formata o código e corrige problemas de linting
bun run typecheck # Verifica que nenhum tipo está quebradoPor que isso é obrigatório?
code:fixgarante que o código segue o padrão visual do projeto (indentação, imports, etc.).typecheckgarante que o TypeScript compila sem erros — se falhar, algo está quebrado e não deve ser commitado.
bun run test # Executa os testes unitáriosSe algum teste falhar, corrija antes de commitar. Commits com testes quebrados não devem chegar ao PR.
git add . # Adiciona todas as alterações
git commit -m "feat(campus): adicionar validação de CNPJ" # Cria o commit com mensagem
git add .adiciona todos os arquivos modificados. Se quiser adicionar apenas alguns, usegit add caminho/do/arquivo.ts.
git push origin feat/minha-feature # Envia a branch para o repositório remotoNa primeira vez que fizer push de uma branch nova, o Git pode pedir para configurar o upstream. Use o comando que ele sugerir.
- Acesse o repositório no GitHub.
- Você verá um banner sugerindo abrir um PR para a branch que acabou de enviar — clique nele.
- Preencha o título (seguindo a convenção de commit) e a descrição.
- Adicione revisores.
- Clique em Create Pull Request.
stateDiagram-v2
[*] --> Draft: Abre PR como rascunho\n(ainda trabalhando)
[*] --> ReadyForReview: Abre PR pronto\npara revisão
Draft --> ReadyForReview: Marca como pronto
ReadyForReview --> InReview: Revisor começa\na analisar
InReview --> ChangesRequested: Revisor pede\nalterações
InReview --> Approved: Revisor aprova
ChangesRequested --> InReview: Dev faz correções\ne pede re-review
Approved --> Merged: Merge na main
Merged --> [*]
note right of Draft: Use Draft quando\nainda não terminou
note right of Approved: CI deve estar verde\nantes do merge
Dicas:
- Abra o PR como Draft se ainda estiver trabalhando e quiser feedback antecipado.
- PRs menores são revisados mais rápido — prefira PRs focados a PRs gigantes.
- Responda aos comentários da revisão e faça as correções na mesma branch.
| Fazer | NÃO fazer |
|---|---|
| Criar uma branch por feature/fix | Commitar direto na main |
| Commits pequenos e frequentes com mensagens claras | Um commit gigante com "várias coisas" |
Rodar code:fix + typecheck antes de todo commit |
Commitar com erros de tipo ou formatação |
Rodar bun run test antes de abrir PR |
Abrir PR com testes falhando |
Manter branch atualizada com git fetch -p && git merge origin/main |
Trabalhar semanas sem sincronizar |
| Escrever título de PR descritivo | Título genérico como "Update" |
| Fazer PRs pequenos e focados | PR com 50 arquivos e 3 features misturadas |
| Pedir revisão após CI verde | Pedir revisão com CI falhando |
| Resolver conflitos com cuidado | Forçar push (--force) sem entender |
| Deletar a branch após merge | Acumular branches antigas |
Antes de cada git commit, verifique:
-
bun run code:fixexecutado (sem erros). -
bun run typecheckpassando. - Mensagem de commit segue o padrão
tipo(escopo): descrição. - Nenhum
console.logde debug esquecido. - Nenhum arquivo sensível (
.env, credenciais) incluído.
Antes de abrir o Pull Request:
-
bun run code:fixexecutado. -
bun run typecheckpassando. -
bun run testpassando. - Branch atualizada com a main (
git fetch -p && git merge origin/main). - Novos endpoints documentados no Swagger (decorators
@ApiOperation,@ApiTags). - Migrações criadas se houve alteração em entidades do banco.
- README atualizado se houve mudança em estrutura, variáveis, serviços ou fluxos.
- PR com título descritivo seguindo Conventional Commits.
- Descrição do PR explicando o que foi feito e por quê.
Nota: todo código roda dentro do container. Se você não estiver no shell do container (via
just up), usejust exec <comando>para executar de fora. Exemplo:just exec bun run typecheck.
Commits são o histórico permanente do projeto. Um bom commit permite que qualquer pessoa entenda o que foi feito, por que, e em qual contexto — mesmo meses depois.
Todos os commits neste projeto devem seguir o padrão Conventional Commits:
tipo(escopo): descrição imperativa curta
Corpo opcional com mais detalhes sobre o que mudou e por quê.
Pode ter múltiplas linhas.
Refs #123
Estrutura:
| Parte | Obrigatório | Descrição |
|---|---|---|
| tipo | sim | Categoria da mudança (feat, fix, refactor, etc.) |
| escopo | não (mas recomendado) | Módulo ou área afetada (campus, auth, database) |
| descrição | sim | Frase curta no imperativo (ex.: "adicionar", não "adicionado" ou "adicionando") |
| corpo | não | Detalhes adicionais — o porquê da mudança, contexto, decisões |
| referência | não | Link para issue (Refs #123, Closes #45) |
Tipos permitidos:
| Tipo | Quando usar | Exemplo |
|---|---|---|
feat |
Nova funcionalidade visível ao usuário | feat(turma): adicionar endpoint de matrícula |
fix |
Correção de bug | fix(campus): corrigir filtro de busca por CNPJ |
refactor |
Mudança interna sem alterar comportamento | refactor(auth): extrair validação de token para service |
docs |
Documentação (README, comentários, Swagger) | docs: atualizar variáveis de ambiente no README |
test |
Adição ou correção de testes | test(diario): adicionar testes do create handler |
chore |
Manutenção (deps, CI, config, build) | chore: atualizar NestJS para v11 |
style |
Formatação (sem mudança de lógica) | style: aplicar code:fix no módulo campus |
perf |
Melhoria de performance | perf(database): adicionar índice na tabela turma |
ci |
Alteração em pipelines CI/CD | ci: adicionar step de typecheck no workflow |
Exemplos completos:
# Commit simples (uma linha)
git commit -m "feat(campus): adicionar validação de CNPJ duplicado"
# Commit com corpo explicativo
git commit -m "fix(turma): corrigir erro 500 ao listar turmas sem diário
O findAll retornava erro quando a turma não tinha diários associados
porque o LEFT JOIN não tratava o caso de relação vazia.
Refs #127"
# Commit de refatoração
git commit -m "refactor(auth): mover mock token para infrastructure.identity-provider
O mock de token estava no controller, violando a separação de concerns.
Movido para o adapter de identity provider onde pertence."O que NÃO fazer em commits:
| Ruim | Por quê | Bom |
|---|---|---|
fix |
Não diz o que foi corrigido | fix(campus): corrigir paginação na listagem |
update |
Genérico demais | feat(turma): adicionar campo observacao |
wip |
Não deve ser commitado — use stash | Finalize antes de commitar |
ajustes diversos |
Múltiplas mudanças misturadas | Separe em commits focados |
Adicionado endpoint |
Não segue o padrão (não é imperativo, sem tipo) | feat(campus): adicionar endpoint de exclusão |
Issues são o ponto de partida de qualquer alteração. Uma boa issue permite que qualquer dev (inclusive você mesmo no futuro) entenda o problema ou a necessidade sem precisar perguntar.
Para bugs:
## Descrição do bug
O que está acontecendo de errado? Qual o comportamento atual?
## Comportamento esperado
O que deveria acontecer?
## Como reproduzir
1. Acessar endpoint X com payload Y
2. Observar resposta Z
## Contexto adicional
- Ambiente: desenvolvimento / produção
- Endpoint: POST /api/campi
- Payload de exemplo (se aplicável)
- Logs de erro (se disponíveis)Para features:
## Descrição
O que precisa ser implementado e por quê?
## Critérios de aceite
- [ ] Endpoint POST /api/turmas criado
- [ ] Validação de campos obrigatórios
- [ ] Testes unitários do handler
- [ ] Documentação Swagger
## Contexto técnico (se aplicável)
Módulo afetado, dependências, decisões de design.Dicas:
- Título claro e específico — "Erro 500 ao criar campus sem endereço" é melhor que "Bug no campus".
- Uma issue por problema/feature — não misture assuntos.
- Use labels para categorizar (
bug,feature,enhancement,docs). - Referencie issues relacionadas quando existirem.
O PR é onde a revisão acontece. Um bom PR facilita a vida do revisor e acelera o merge.
## O que foi feito
Resumo em 1-3 frases do que esta PR implementa/corrige.
## Por que
Contexto e motivação — qual problema resolve ou qual necessidade atende.
Link para a issue: Closes #123
## Como testar
1. Subir o ambiente com `just up`
2. Rodar migrações: `bun run migration:run`
3. Acessar POST /api/campi com payload X
4. Verificar resposta Y
## Checklist
- [ ] `code:fix` executado
- [ ] `typecheck` passando
- [ ] Testes passando
- [ ] Swagger atualizado (se aplicável)
- [ ] README atualizado (se aplicável)Regras:
| Regra | Descrição |
|---|---|
| PRs pequenos | Máximo ~400 linhas alteradas. Se passou disso, considere dividir. |
| Uma responsabilidade | Cada PR resolve um problema ou implementa uma feature. Não misture. |
| Título descritivo | Segue Conventional Commits: feat(campus): adicionar validação de CNPJ |
| Descrição completa | O revisor não deve precisar ler todo o diff para entender o contexto. |
| CI verde | Não peça revisão com CI falhando. |
| Branch atualizada | Faça git fetch -p && git merge origin/main antes de pedir revisão. |
| Resolva conflitos | Se houver conflitos com a main, resolva antes do merge. |
Com o fluxo de contribuição claro, agora vamos entender como o código é organizado internamente. Isso vai ajudar você a saber onde colocar cada alteração e por que os arquivos estão onde estão.
Imagine um restaurante. Se o cozinheiro, o garçom, o caixa e o fornecedor estivessem todos na mesma sala fazendo tudo junto, qualquer mudança (trocar o fornecedor, mudar o cardápio, aceitar um novo tipo de pagamento) afetaria todo mundo. Agora imagine que cada um tem seu espaço separado e se comunicam por pedidos padronizados — mudar o fornecedor não afeta o garçom, e o cozinheiro não precisa saber como o pagamento funciona.
Este projeto segue essa mesma ideia: cada parte do código tem uma responsabilidade clara e se comunica com as outras através de contratos (interfaces). Isso permite trocar peças sem quebrar o resto.
graph LR
subgraph "Sem organização"
MONO["Todo o código junto\n(banco, lógica, HTTP, auth)\n→ mudar uma coisa quebra outra"]
end
subgraph "Com arquitetura hexagonal"
PRES["Apresentação\n(recebe requisições)"]
APP["Aplicação\n(orquestra a lógica)"]
DOM["Domínio\n(regras de negócio)"]
INFRA["Infraestrutura\n(banco, auth, filas)"]
PRES --> APP --> DOM
INFRA --> DOM
end
style MONO fill:#e74c3c,stroke:#c0392b,color:#fff,text-align:left
style DOM fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style PRES fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style INFRA fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
Na prática, quando você precisa adicionar um novo campo a uma entidade (ex.: "telefone" no Campus):
- Adiciona o campo no domínio (
campus.tsecampus.schemas.ts) - Atualiza a entidade do banco na infraestrutura (
campus.typeorm.entity.ts) - Gera uma migração (
bun run typeorm:generate) - Atualiza os DTOs na apresentação (REST e/ou GraphQL)
- Pronto — os handlers da aplicação não mudam porque delegam para o domínio
Essa separação é o que chamamos de arquitetura hexagonal.
O projeto segue a arquitetura hexagonal (também conhecida como ports & adapters). Em termos simples: a lógica de negócio (domínio) fica no "centro" e não sabe nada sobre o mundo exterior. Ela define contratos — como "preciso de um repositório que salve Campus" — e as camadas externas fornecem implementações — como "aqui está um repositório que usa PostgreSQL". O termo port (porta) se refere ao contrato/interface, e adapter (adaptador) se refere à implementação concreta.
O que isso significa na prática? Se amanhã o banco de dados mudar de PostgreSQL para outro, ou se o Keycloak for substituído por outro provedor de autenticação, apenas a camada de infraestrutura precisa ser alterada — a lógica de negócio permanece intacta.
graph TD
A["🖥️ Apresentação\n(REST controllers, GraphQL resolvers)"]
B["⚙️ Aplicação\n(command handlers, query handlers, autorização)"]
C["🏛️ Domínio\n(entidades, contratos de repositório, erros,\nvalidação, abstrações de serviços externos)"]
D["🔌 Infraestrutura\n(TypeORM, Keycloak, RabbitMQ, filesystem, config)"]
A -- "chama" --> B
B -- "usa interfaces de" --> C
D -- "implementa contratos de" --> C
style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
style B fill:#7b68ee,stroke:#5a4db0,color:#fff
style C fill:#e8a838,stroke:#b07c1e,color:#fff
style D fill:#50b86c,stroke:#3a8a50,color:#fff
O fluxo de dependência sempre aponta para dentro: a apresentação depende da aplicação, que depende do domínio. A infraestrutura implementa os contratos do domínio, mas o domínio nunca referencia a infraestrutura diretamente.
A arquitetura hexagonal se apoia no princípio de inversão de dependência: código de alto nível (lógica de negócio) não deve depender de código de baixo nível (banco de dados, frameworks). Em vez disso, ambos dependem de abstrações (interfaces).
A analogia: uma tomada elétrica é uma interface padrão. O eletricista (domínio) instala a tomada (interface) sem saber que aparelho será plugado. O aparelho (infraestrutura) precisa ter o plug compatível. Se o aparelho mudar, a tomada continua a mesma.
graph TD
subgraph "Sem inversão (acoplado)"
H1["Handler"] --> R1["CampusTypeormRepository"]
R1 --> DB1["PostgreSQL"]
style H1 fill:#e74c3c,stroke:#c0392b,color:#fff
end
subgraph "Com inversão (desacoplado)"
H2["Handler"]
I["ICampusRepository\n(interface/port)"]
R2["CampusTypeormRepository\n(adapter)"]
DB2["PostgreSQL"]
H2 -- "depende da\nabstração" --> I
R2 -- "implementa" --> I
R2 --> DB2
end
style I fill:#e8a838,stroke:#b07c1e,color:#fff
style H2 fill:#4a90d9,stroke:#2c5f8a,color:#fff
style R2 fill:#50b86c,stroke:#3a8a50,color:#fff
Neste projeto, o domínio define Symbols (tokens de injeção) e types (contratos). A infraestrutura registra implementações concretas para esses Symbols. O NestJS injeta a implementação correta em runtime. Exemplo: ICampusRepository (Symbol + type no domínio) é implementado por CampusTypeormRepository (na infraestrutura).
Exemplo concreto — trocar a infraestrutura sem tocar no handler:
graph LR
HANDLER["CampusCreateCommandHandler\n(não muda nunca)"]
subgraph "Produção"
I1["ICampusRepository"] --> IMPL1["CampusTypeormRepository\n→ PostgreSQL"]
end
subgraph "Testes"
I2["ICampusRepository"] --> IMPL2["MockCrudRepository\n→ memória (vi.fn)"]
end
subgraph "Futuro hipotético"
I3["ICampusRepository"] --> IMPL3["CampusPrismaRepository\n→ Prisma ORM"]
end
HANDLER --> I1
HANDLER -.-> I2
HANDLER -.-> I3
style HANDLER fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style IMPL1 fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style IMPL2 fill:#7b68ee,stroke:#5a4db0,color:#fff,text-align:left
style IMPL3 fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
Para ir mais fundo: a diferença entre inversão de dependência e injeção de dependência é sutil mas importante. Inversão de dependência é um princípio de design — o domínio define interfaces, e a infraestrutura implementa. Injeção de dependência é um mecanismo técnico — o container (NestJS) resolve e injeta as implementações via constructor. Neste projeto, os Symbols do TypeScript funcionam como tokens de injeção porque TypeScript não emite interfaces em runtime — o Symbol é a referência concreta que o container usa para resolver a dependência. Essa abordagem é um pragmatismo aceito: tecnicamente,
DeclareDependency(que internamente usa@Injectdo NestJS) cria um acoplamento do domínio com o NestJS, mas na prática é um decorator fino que não afeta a testabilidade.
CQRS é a prática de separar operações de leitura (queries) de operações de escrita (commands) em handlers distintos. A analogia: em um restaurante, quem anota os pedidos (garçom) e quem prepara a comida (cozinheiro) são pessoas diferentes com habilidades diferentes — mesmo que ambos trabalhem com "comida".
Neste projeto, cada módulo tem handlers separados para commands (Create, Update, Delete) e queries (FindOne, List). Commands alteram o estado do banco e exigem verificação de permissão. Queries apenas leem dados — atualmente aceitam acesso público, mas no roadmap está prevista a filtragem de resultados com base nas permissões do usuário (o usuário verá apenas os registros que "pode" ver).
graph LR
subgraph "Escrita (Commands)"
C1["Create"]
C2["Update"]
C3["Delete"]
end
subgraph "Leitura (Queries)"
Q1["FindById"]
Q2["FindAll\n(paginação)"]
end
REQ["Requisição\n(REST / GraphQL)"] --> AC["AccessContext\n(usuário autenticado)"]
AC --> C1 & C2 & C3
AC --> Q1 & Q2
C1 & C2 & C3 --> REPO["Repositório\n(escrita)"]
Q1 & Q2 --> REPO2["Repositório\n(leitura)"]
style REQ fill:#4a90d9,stroke:#2c5f8a,color:#fff
style AC fill:#e8a838,stroke:#b07c1e,color:#fff
style REPO fill:#50b86c,stroke:#3a8a50,color:#fff
style REPO2 fill:#50b86c,stroke:#3a8a50,color:#fff
Para ir mais fundo: a separação facilita otimização independente — leituras podem ter cache, índices especializados e projeções otimizadas, enquanto escritas passam por validação completa, autorização e transações. Neste projeto, o CQRS é lógico (handlers separados, mesmo banco) — não há Event Sourcing ou bancos separados para leitura/escrita, embora a arquitetura permita evoluir nessa direção no futuro.
Exemplo concreto — módulo Campus:
graph TD
subgraph "Commands (escrita)"
CC["CampusCreateCommandHandler\nPOST /api/campi"]
CU["CampusUpdateCommandHandler\nPATCH /api/campi/:id"]
CD["CampusDeleteCommandHandler\nDELETE /api/campi/:id"]
end
subgraph "Queries (leitura)"
QF["CampusFindOneQueryHandler\nGET /api/campi/:id"]
QL["CampusListQueryHandler\nGET /api/campi"]
end
CC & CU & CD --> |"verificam\npermissão"| PC["PermissionChecker"]
CC & CU & CD --> |"usam"| ENT["Campus\n(entidade de domínio)"]
CC & CU & CD --> |"persistem via"| REPO_W["ICampusRepository\n(create, update, softDelete)"]
QF & QL --> |"leem via"| REPO_R["ICampusRepository\n(findById, findAll)"]
Note1["Queries: hoje aceitam acesso público.\nNo roadmap: filtrar resultados\npor permissão do usuário."]
style CC fill:#e74c3c,stroke:#c0392b,color:#fff
style CU fill:#e74c3c,stroke:#c0392b,color:#fff
style CD fill:#e74c3c,stroke:#c0392b,color:#fff
style QF fill:#4a90d9,stroke:#2c5f8a,color:#fff
style QL fill:#4a90d9,stroke:#2c5f8a,color:#fff
O projeto usa o NestJS v11 como framework. O NestJS é um framework para construir aplicações server-side em TypeScript — ele fornece uma estrutura opinada para organizar o código, gerenciar dependências e lidar com requisições HTTP e GraphQL. Se você já usou frameworks como Spring (Java) ou Django (Python), o NestJS segue uma filosofia similar.
Se você nunca usou NestJS, aqui estão os conceitos essenciais para entender o código:
O NestJS organiza a aplicação em peças que se encaixam:
graph TD
subgraph "Organização"
MOD["Module\nAgrupa e organiza"]
CTRL["Controller\nRecebe requisições"]
PROV["Provider / Service\nLógica injetável"]
end
subgraph "Pipeline de requisição"
MW["Middleware\n(antes de tudo)"]
GD["Guard\n(autenticação)"]
PP["Pipe\n(validação)"]
IT["Interceptor\n(transação, logging)"]
FT["Filter\n(tratamento de erros)"]
end
MOD --> CTRL
MOD --> PROV
CTRL -.-> PROV
MW --> GD --> PP --> CTRL
CTRL --> IT
IT -.-> FT
style MOD fill:#e8a838,stroke:#b07c1e,color:#fff
style CTRL fill:#4a90d9,stroke:#2c5f8a,color:#fff
style PROV fill:#50b86c,stroke:#3a8a50,color:#fff
| Conceito | O que é | Neste projeto |
|---|---|---|
| Module | Unidade organizacional que agrupa controllers e providers. AppModule é a raiz, e cada módulo de feature tem seu próprio module. |
AppModule importa todos os módulos. Cada módulo (ex.: CampusModule) registra seus handlers, repositórios e controllers. |
| Controller | Classe que recebe requisições HTTP e delega para providers. Usa decorators como @Controller('/path'), @Get(), @Post(), @Body(), @Param(). |
Controllers ficam em presentation.rest/. Delegam para command/query handlers — nunca contêm lógica de negócio. |
| Provider | Qualquer classe injetável no container de DI. Inclui services, handlers, repositórios, configs. | Handlers (CampusCreateCommandHandlerImpl), repositórios (CampusTypeormRepository), permission checkers — todos são providers. |
| Resolver | Equivalente ao Controller, mas para GraphQL. Usa @Resolver(), @Query(), @Mutation(). |
Resolvers ficam em presentation.graphql/. Reutilizam os mesmos handlers do REST. |
Quando uma requisição chega ao NestJS, ela não vai direto para o controller — ela passa por uma "esteira" de etapas, onde cada etapa tem um papel específico. Pense como uma linha de montagem: cada estação verifica ou transforma algo antes de passar adiante.
As etapas dessa esteira são:
- Middleware — código que executa antes de tudo. Pode modificar a requisição ou resposta. Exemplo: adicionar um ID de rastreamento.
- Guard (guarda) — decide se a requisição pode prosseguir. É onde a autenticação acontece. Se o token for inválido, a requisição para aqui.
- Pipe (tubo/filtro) — transforma e/ou valida os dados de entrada. Se o body da requisição estiver malformado, a requisição é rejeitada aqui.
- Interceptor (interceptador) — envolve a execução do handler. Pode agir antes e depois da lógica principal. Usado para transações e logging.
- Filter (filtro de exceção) — captura erros que ocorreram em qualquer etapa e formata uma resposta de erro padronizada.
Visualmente:
graph LR
REQ["Requisição HTTP"] --> MW["Middleware\nCorrelation ID"]
MW --> GD["Guard\nExtrai Bearer token\ne valida JWT"]
GD --> PP["Pipe\nZodGlobalValidationPipe\nvalida body/params"]
PP --> CTRL["Controller\nDelega para handler"]
CTRL --> INT["Interceptor\nTransactionInterceptor\nabre transação"]
INT --> HANDLER["Handler\nexecuta lógica"]
HANDLER --> INT2["Interceptor\ncommit ou rollback"]
INT2 --> RESP["Resposta HTTP"]
HANDLER -.-> |"erro"| FT["Filter\nApplicationErrorFilter\nformata erro HTTP"]
FT --> RESP
style REQ fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style MW text-align:left
style GD text-align:left
style PP text-align:left
style CTRL fill:#7b68ee,stroke:#5a4db0,color:#fff,text-align:left
style INT text-align:left
style HANDLER fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style INT2 text-align:left
style RESP fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style FT text-align:left
| Etapa | Papel | Exemplo neste projeto |
|---|---|---|
| Middleware | Executa antes de tudo. Pode modificar request/response. | correlationIdMiddleware — gera um ID único por requisição para rastreamento em logs (src/infrastructure.logging/). |
| Guard | Decide se a requisição pode prosseguir (autenticação). Retorna true/false. |
Valida o Bearer token via JWKS (ou mock token em dev) e popula o RequestActor (src/server/nest/auth/). |
| Pipe | Transforma e/ou valida dados de entrada (body, params, query). | ZodGlobalValidationPipe — valida o body contra o static schema do DTO. Se inválido, retorna 400 (src/shared/validation/zod-global-validation.pipe.ts). |
| Controller | Recebe a requisição já validada, extrai o ator (@AccessContextHttp()) e delega para o handler. |
CampusRestController.create() chama campusCreateCommandHandler.execute(). |
| Interceptor | Envolve a execução do handler (antes e depois). | TransactionInterceptor — abre uma transação antes do handler e faz commit/rollback após (src/server/nest/interceptors/transaction.interceptor.ts). |
| Filter | Captura exceções e formata a resposta de erro. | ApplicationErrorFilter — converte ForbiddenError em HTTP 403, ValidationError em 422 com detalhes por campo (src/server/nest/filters/). |
Dependency Injection (DI — Injeção de Dependência) é um padrão onde uma classe não cria suas dependências — ela apenas declara "preciso de X" e o framework fornece X automaticamente. Isso é fundamental para a arquitetura hexagonal: o handler diz "preciso de um repositório" sem saber se é PostgreSQL, memória ou qualquer outra coisa.
O NestJS resolve dependências automaticamente. Você declara o que precisa no constructor, e o framework injeta:
// O NestJS vê que o constructor precisa de ICampusRepository
// e automaticamente injeta a classe registrada para esse Symbol
constructor(
@Inject(ICampusRepository) private readonly repo: ICampusRepository,
) {}Neste projeto, usamos Symbols como tokens de injeção. Um Symbol no TypeScript é um identificador único e imutável — como um número de CPF, que garante que nunca haverá confusão entre duas coisas com o mesmo nome. Usamos Symbols em vez de classes porque TypeScript não emite interfaces em tempo de execução — o Symbol é a referência concreta que o container usa para saber qual implementação entregar:
Symbol("ICampusRepository")— token de injeção (definido no domínio)@DeclareDependency(token)— solicita a injeção de uma dependência (wrapper para@Inject)@DeclareImplementation()— registra uma classe como provider injetável (wrapper para@Injectable)@Inject(token)— solicita a injeção da implementação registrada
A camada mais interna e mais protegida. Contém a lógica de negócio pura — sem dependência de frameworks, bancos de dados ou protocolos.
graph TD
subgraph "src/domain/"
ENT["Entidades\nCampus, Turma, Diario..."]
SCH["Schemas Zod\nCampusSchema, CampusCreateSchema..."]
ABS["Abstrações\nIRepositoryCreate, IRepositoryFindAll\nIPermissionChecker, IAccessContext"]
SCA["Scalars\nIdUuid, IdNumeric\nScalarDateTimeString"]
ERR["Erros\nEntityValidationError\nBusinessRuleViolationError"]
DI["Dependency Injection\nDeclareDependency\nDeclareImplementation"]
end
ENT --> SCH
ENT --> SCA
ENT --> ERR
style ENT fill:#e8a838,stroke:#b07c1e,color:#fff
style ABS fill:#e8a838,stroke:#b07c1e,color:#fff
O que contém:
- Entidades — classes com constructor privado, factory methods (
create,load,update) e validação Zod interna. - Schemas Zod — definem a forma dos dados.
EntitySchema,CreateSchema(sem id/datas),UpdateSchema(parcial). - Abstrações — interfaces que definem contratos:
IRepositoryCreate<T>,IRepositoryFindAll<T>,IPermissionChecker,IAccessContext. - Scalars — type aliases semânticos:
IdUuidem vez destring,ScalarDateTimeStringem vez destring. - Erros de domínio —
EntityValidationError,BusinessRuleViolationError,InvalidStateError,InvariantViolationError(emsrc/domain/errors/). - DI decorators —
DeclareDependency,DeclareImplementationpara registrar no container.
Regra de ouro: o domínio nunca importa de infrastructure.*, server/, ou qualquer framework. Ele define o que precisa, não como é feito.
Para entender como os identificadores das entidades funcionam neste projeto, veja o conceito de UUID v7:
Um UUID (Universally Unique Identifier) é um identificador de 128 bits que é único no universo — como um CPF para cada registro no banco, mas gerado automaticamente sem coordenação central.
graph LR
subgraph "UUID v4 (aleatório)"
V4["550e8400-e29b-41d4-a716-446655440000\n(bits totalmente aleatórios)"]
end
subgraph "UUID v7 (temporal + aleatório)"
V7_T["01906b5a-c8e3\n(timestamp)"]
V7_R["-7c14-b59a-2f1e4a3b7c9d\n(aleatório)"]
V7_T --- V7_R
end
V7_T -.-> |"ordenação\ncronológica"| IDX["Índice B-tree\n(inserções sequenciais\n= menos fragmentação)"]
style V7_T fill:#50b86c,stroke:#3a8a50,color:#fff
style IDX fill:#336791,stroke:#1e3d5c,color:#fff
Neste projeto, usamos UUID v7 (implementado via uuid v13, em src/domain/entities/utils/generate-uuid-v7.ts). A diferença da versão mais comum (v4, que é aleatória) é que o UUID v7 inclui um componente temporal — os primeiros bits codificam o timestamp de criação.
Para ir mais fundo: a vantagem do UUID v7 sobre o v4 é a ordenação cronológica natural. Como os primeiros bits são o timestamp, UUIDs mais novos são lexicograficamente maiores que UUIDs mais antigos. Isso melhora significativamente a performance de índices B-tree no PostgreSQL — inserções são sequenciais em vez de aleatórias, reduzindo page splits e fragmentação. Na prática, tabelas com milhões de registros indexados por UUID v7 têm performance de leitura e escrita consideravelmente melhor que com UUID v4. A exceção neste projeto são
EstadoeCidade, que usam IDs numéricos do IBGE.
Orquestra o domínio. Recebe uma intenção do usuário (command/query), verifica permissões e coordena a execução.
graph LR
INPUT["input (unknown)"] --> HANDLER["Command/Query Handler"]
AC["AccessContext\n(usuário)"] --> HANDLER
HANDLER --> PC["Permission Checker\nensureCanCreate()"]
HANDLER --> ENT["Entidade.create(input)\n(domínio)"]
HANDLER --> REPO["Repository.create(entity)\n(interface do domínio)"]
style INPUT text-align:left
style AC text-align:left
style PC text-align:left
style HANDLER fill:#7b68ee,stroke:#5a4db0,color:#fff,text-align:left
style ENT fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style REPO fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
O que contém:
- Command handlers —
CreateCommandHandler,UpdateCommandHandler,DeleteCommandHandler. RecebemaccessContext+input, verificam permissão, criam/atualizam entidade, chamam repositório. - Query handlers —
FindOneQueryHandler,ListQueryHandler. Delegam leitura para o repositório. - Permission checkers — implementações de
IPermissionChecker. Verificam se o usuário pode executar a operação. - Erros de aplicação —
ResourceNotFoundError(404),ForbiddenError(403),UnauthorizedError(401),ValidationError(422),ConflictError(409),InternalError(500),ServiceUnavailableError(503) — emsrc/application/errors/. - Helpers — utilitários de imagem, paginação.
Papel: é a camada de "orquestração". Não contém regras de negócio (essas ficam no domínio) nem detalhes de persistência (esses ficam na infraestrutura).
Implementa os contratos do domínio com tecnologias concretas. Cada concern tem seu próprio diretório infrastructure.*:
graph TD
subgraph "Contratos (domínio)"
IR["IRepositoryCreate"]
IIP["IIdentityProvider"]
IMB["IMessageBrokerService"]
IS["IStorageService"]
end
subgraph "Implementações (infraestrutura)"
TR["TypeORM Repository\ninfrastructure.database"]
KC["Keycloak Service\ninfrastructure.identity-provider"]
RMQ["Rascal Service\ninfrastructure.message-broker"]
FS["Filesystem Service\ninfrastructure.storage"]
end
IR -.-> TR
IIP -.-> KC
IMB -.-> RMQ
IS -.-> FS
TR --> PG["PostgreSQL"]
KC --> KCS["Keycloak Server"]
RMQ --> RMQS["RabbitMQ Server"]
FS --> DISK["Filesystem"]
style IR fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style IIP text-align:left
style IMB text-align:left
style IS text-align:left
style TR fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style KC text-align:left
style RMQ text-align:left
style FS text-align:left
| Diretório | Tecnologia | O que implementa |
|---|---|---|
infrastructure.database |
TypeORM + PostgreSQL | Repositórios, migrações (58 arquivos), paginação, connection proxy (transações) |
infrastructure.identity-provider |
Keycloak + JWKS | Validação de tokens, obtenção de info do usuário, admin client |
infrastructure.message-broker |
RabbitMQ via Rascal | Publicação e consumo de mensagens (filas de geração de horário) |
infrastructure.storage |
Filesystem + Sharp | Upload, armazenamento e redimensionamento de imagens/arquivos |
infrastructure.config |
NestJS ConfigModule | Leitura de variáveis de ambiente, opções de runtime, auth, database, broker |
infrastructure.graphql |
Apollo Server | Configuração do GraphQL, DTOs base, cache LRU |
infrastructure.logging |
Middleware customizado | Correlation ID para rastreamento de requisições |
infrastructure.authorization |
Implementações locais | Permission checkers concretos |
infrastructure.timetable-generator |
Contratos de mensagem | Tipos e comandos para geração de horários |
infrastructure.dependency-injection |
NestJS DI | Configuração do container de injeção de dependência |
Papel: é a única camada que "sabe" qual banco de dados, qual provedor de auth, ou qual broker está sendo usado. Se trocar PostgreSQL por MySQL, apenas infrastructure.database muda.
Para entender como a comunicação assíncrona funciona na camada de infraestrutura, veja o conceito de message broker:
Um message broker é um intermediário de mensagens assíncronas entre serviços. É como um correio: um serviço deposita uma carta (mensagem) na caixa postal (fila) e outro serviço retira quando estiver pronto — os dois não precisam estar online ao mesmo tempo.
graph LR
subgraph "Produtor"
P["Management Service\n(publica mensagem)"]
end
subgraph "RabbitMQ (broker)"
EX["Exchange\n(roteador)"]
Q1["Fila request\ndev.timetable_generate.request"]
Q2["Fila response\ndev.timetable_generate.response"]
EX --> Q1
end
subgraph "Consumidor"
C["Timetable Generator\n(processa e responde)"]
end
P -- "publica" --> EX
Q1 -- "entrega" --> C
C -- "responde" --> Q2
Q2 -- "entrega resposta" --> P
style P fill:#4a90d9,stroke:#2c5f8a,color:#fff
style EX fill:#ff6600,stroke:#b34700,color:#fff
style Q1 fill:#ff6600,stroke:#b34700,color:#fff
style Q2 fill:#ff6600,stroke:#b34700,color:#fff
style C fill:#50b86c,stroke:#3a8a50,color:#fff
Dois padrões de comunicação:
graph TD
subgraph "Padrão 1: RPC (Request/Response)"
RPC_P["Management Service"] -- "publica request" --> RPC_Q1["Fila request"]
RPC_Q1 --> RPC_C["Timetable Generator"]
RPC_C -- "publica response" --> RPC_Q2["Fila response"]
RPC_Q2 --> RPC_P
RPC_P -.-> |"espera com\ntimeout (60s)"| RPC_P
end
subgraph "Padrão 2: Fire-and-Forget"
FF_P["Management Service"] -- "publica\n(não espera)" --> FF_Q["Fila request"]
FF_Q --> FF_C["Timetable Generator"]
end
style RPC_P fill:#4a90d9,stroke:#2c5f8a,color:#fff
style FF_P fill:#4a90d9,stroke:#2c5f8a,color:#fff
Neste projeto, o RabbitMQ é usado via biblioteca Rascal v21 para geração automática de horários. O Management Service publica uma requisição na fila dev.timetable_generate.request e consome a resposta de dev.timetable_generate.response quando o Timetable Generator (serviço externo) completar o processamento. A interface IMessageBrokerService está em src/domain/abstractions/message-broker/.
Para ir mais fundo: o Rascal é um wrapper sobre AMQP que adiciona gerenciamento de conexão, retry e configuração declarativa. O projeto implementa dois padrões: RPC (request/response — publica e espera resposta com timeout) e fire-and-forget (publica sem esperar). As filas são configuráveis via variáveis
MESSAGE_BROKER_QUEUE_TIMETABLE_REQUESTeMESSAGE_BROKER_QUEUE_TIMETABLE_RESPONSE. A UI do RabbitMQ está disponível emhttp://localhost:15672(admin/admin).
Traduz protocolos externos (HTTP, GraphQL) em chamadas para a camada de aplicação e formata as respostas.
graph LR
subgraph "REST (presentation.rest/)"
CTRL["Controller\n@Controller('/path')\n@Post, @Get,\n@Patch, @Delete"]
DTO_IN["DTO de entrada\nstatic schema (Zod)"]
DTO_OUT["DTO de saída\nSwagger decorators"]
end
subgraph "GraphQL (presentation.graphql/)"
RES["Resolver\n@Resolver\n@Query, @Mutation"]
GQL_DTO["GraphQL DTO\n@ObjectType\n@Field"]
end
CTRL --> HANDLER["Handler\n(aplicação)"]
RES --> HANDLER
style CTRL fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style RES fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style HANDLER fill:#7b68ee,stroke:#5a4db0,color:#fff,text-align:left
style DTO_IN fill:none,stroke:none,text-align:left
style DTO_OUT fill:none,stroke:none,text-align:left
style GQL_DTO fill:none,stroke:none,text-align:left
O que contém:
- Controllers REST — recebem HTTP, validam DTO (via Zod pipe), extraem
AccessContext, delegam para handler. Sempre com@ApiTagse@ApiOperationpara documentação Swagger. - Resolvers GraphQL — equivalente ao controller, mas para queries/mutations GraphQL. Reutilizam os mesmos handlers.
- DTOs de entrada — classes com
static schema(Zod) para validação automática. O schema é reutilizado do domínio. - DTOs de saída — definem a forma da resposta (REST com tipos TypeScript, GraphQL com
@ObjectType/@Field). - Mappers — convertem entre formatos de domínio e apresentação.
Regra: a apresentação nunca acessa o banco diretamente. Ela sempre delega para handlers da aplicação.
Para entender como dados são transportados entre camadas na apresentação, veja o conceito de DTO:
Um DTO é um objeto que existe apenas para transportar dados entre camadas — ele não contém lógica de negócio. Pense como um formulário padronizado: define quais campos existem e quais são obrigatórios, mas não processa nada.
graph LR
CLIENT["Cliente\n(front-end)"] -- "JSON de entrada\n{nomeFantasia, cnpj}" --> DTO_IN["DTO de Entrada\nCampusCreateInputRestDto\n+ static schema (Zod)"]
DTO_IN -- "dados validados" --> HANDLER["Handler"]
HANDLER -- "resultado" --> DTO_OUT["DTO de Saída\nCampusFindOneOutputRestDto\n{\n id, nomeFantasia,\n dateCreated...\n}"]
DTO_OUT -- "JSON de resposta" --> CLIENT
style CLIENT text-align:left
style DTO_IN fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style DTO_OUT fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style HANDLER fill:#7b68ee,stroke:#5a4db0,color:#fff,text-align:left
Neste projeto, existem DTOs de entrada (o que o cliente envia) e DTOs de saída (o que a API retorna). Os DTOs de entrada carregam um static schema Zod que é usado automaticamente pelo ZodGlobalValidationPipe para validar a requisição antes que ela chegue ao controller.
Exemplo concreto — o que o cliente envia vs. o que recebe ao criar um campus:
graph TD
subgraph "Entrada (CampusCreateInputRestDto)"
IN["nomeFantasia: 'IFRO'\nrazaoSocial: 'Instituto Federal'\napelido: 'Ji-Paraná'\ncnpj: '10817343000195'\nendereco: {\n id: 'uuid-...'\n}"]
end
PIPE["ZodGlobalValidationPipe\nvalida com CampusCreateSchema"]
subgraph "Saída (CampusFindOneQueryResult)"
OUT["id: '019...' (UUID v7 gerado)\nnomeFantasia: 'IFRO'\nrazaoSocial: 'Instituto Federal'\napelido: 'Ji-Paraná'\ncnpj: '10817343000195'\nendereco: {\n id, cep, cidade...\n}\ndateCreated: '2026-03-22T...'\ndateUpdated: '2026-03-22T...'"]
end
IN --> PIPE --> |"válido"| OUT
PIPE -.-> |"inválido"| ERR["400 Bad Request\n{\n field: 'cnpj',\n message: 'cnpj é obrigatório'\n}"]
style IN fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style OUT fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style PIPE text-align:left
style ERR fill:#e74c3c,stroke:#c0392b,color:#fff,text-align:left
// src/modules/ambientes/campus/presentation.rest/campus.rest.dto.ts (exemplo simplificado)
export class CampusCreateInputRestDto {
static schema = CampusCreateSchema; // Schema Zod reutilizado do domínio
nomeFantasia!: string;
razaoSocial!: string;
apelido!: string;
cnpj!: string;
endereco!: { ... };
}Para ir mais fundo: a separação entre DTOs de entrada e saída segue o princípio de que o formato dos dados que o cliente envia raramente é idêntico ao que ele recebe. Na criação de um campus, o cliente envia
nomeFantasiaecnpj, mas a resposta inclui tambémid,dateCreated,enderecocompleto com cidade e estado. Ostatic schemano DTO é uma convenção deste projeto — oZodGlobalValidationPipe(emsrc/shared/validation/zod-global-validation.pipe.ts) verifica se ometatypedo parâmetro tem essa propriedade e, se tiver, executaschema.safeParse(value)para validar os dados de entrada automaticamente.
A camada de apresentação oferece duas interfaces para consumo da API:
REST é um estilo de API onde cada recurso tem um endereço fixo (URL) e operações são mapeadas para verbos HTTP: GET (ler), POST (criar), PATCH (atualizar), DELETE (excluir). A resposta sempre traz todos os campos do recurso, mesmo os que você não precisa. É como pedir um prato fixo no restaurante — você recebe tudo que vem, mesmo o que não quer.
GraphQL é uma linguagem de consulta onde o cliente diz exatamente quais campos quer e recebe só aquilo. É como pedir à la carte — você especifica cada item. Com REST, se um front-end precisa de dados de 3 endpoints, faz 3 requisições; com GraphQL faz 1 requisição pedindo tudo junto. Em GraphQL, query é leitura (equivale a GET) e mutation é escrita (equivale a POST/PUT/DELETE).
graph LR
subgraph "REST — 3 requisições"
R1["GET /api/campi/1\n→ {\n id, nomeFantasia,\n razaoSocial, apelido,\n cnpj, endereco,\n dateCreated...\n }"]
R2["GET /api/blocos?campus.id=1\n→ [todos os campos de cada bloco]"]
R3["GET /api/turmas?campus.id=1\n→ [todos os campos de cada turma]"]
end
subgraph "GraphQL — 1 requisição"
GQL["query {\n campusFindOne(id: '1') {\n nomeFantasia\n blocos { nome }\n cursos {\n turmas { periodo }\n }\n }\n}\n→ só os campos pedidos"]
end
style R1 fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style R2 fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style R3 fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style GQL fill:#e535ab,stroke:#b0297f,color:#fff,text-align:left
Neste projeto, a API oferece ambos. REST é a interface principal, com documentação Swagger interativa. GraphQL é uma alternativa flexível para front-ends que precisam de consultas compostas. A abordagem é code-first: em vez de escrever arquivos .graphql, o schema é gerado automaticamente a partir de classes TypeScript decoradas com @ObjectType() e @Field(). Ambas as interfaces reutilizam os mesmos command/query handlers — a lógica de negócio, validação e autorização são idênticas.
Configuração do GraphQL (em src/infrastructure.graphql/graphql.module.ts):
| Configuração | Valor |
|---|---|
| Server | Apollo Server v5.4 com driver NestJS |
| Endpoint | http://localhost:3701/api/graphql |
| Playground | GraphiQL habilitado |
| Introspection | habilitada |
| Cache | LRU em memória (100 MB, TTL de 5 minutos) |
| Schema | code-first (autoSchemaFile: true) |
Para ir mais fundo: manter REST e GraphQL duplica a camada de apresentação (DTOs, mappers) mas não duplica lógica — ambos delegam para os mesmos handlers. O overhead é aceitável porque cada interface serve um propósito diferente: REST para integrações simples e documentação automática, GraphQL para front-ends com necessidades de dados complexas. O projeto não usa DataLoader para resolver o problema N+1 do GraphQL — queries que buscam relações fazem JOINs no repositório TypeORM. Módulos que são apenas REST (
autenticacao,arquivo, módulos deestagio,gerar-horario) não têm resolvers GraphQL.
Visão completa de como uma requisição de criação flui entre todas as camadas, com os artefatos concretos:
graph TD
subgraph "Apresentação"
REQ["POST /api/campi\n+ Bearer token\n+ JSON body"]
MW["Middleware:\ncorrelationIdMiddleware\n→ gera requestId"]
GD["Guard:\nextrai token →\nRequestActor"]
PP["Pipe:\nZodGlobalValidationPipe\n→ valida body com\nCampusCreateInputRestDto\n .schema"]
CTRL["CampusRestController\n .create()\n→ @AccessContextHttp() ac,\n @Body() dto"]
end
subgraph "Aplicação"
INT["Interceptor:\nTransactionInterceptor\n→ abre transação"]
HANDLER["CampusCreateCommand\n HandlerImpl.execute()\n→ recebe accessContext\n + dto"]
PERM["CampusPermissionChecker\n→ ensureCanCreate(\n accessContext\n )"]
end
subgraph "Domínio"
ENT["Campus.create(input)\n→ zodValidate,\n gera UUID v7"]
end
subgraph "Infraestrutura"
REPO["CampusTypeormRepository\n .create()\n→ mapeia para TypeORM\n entity e salva"]
DB["PostgreSQL\n→ INSERT INTO campus"]
end
REQ --> MW --> GD --> PP --> CTRL
CTRL --> INT --> HANDLER
HANDLER --> PERM
HANDLER --> ENT
HANDLER --> REPO
REPO --> DB
DB --> |"commit"| INT
INT --> |"201 Created"| REQ
style REQ fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style MW text-align:left
style GD text-align:left
style PP text-align:left
style CTRL text-align:left
style INT text-align:left
style ENT fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style HANDLER fill:#7b68ee,stroke:#5a4db0,color:#fff,text-align:left
style PERM text-align:left
style REPO fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style DB text-align:left
Resumo do fluxo:
- Requisição chega → Middleware adiciona Correlation ID para logs.
- Guard valida token → extrai
RequestActor(ou rejeita com 401). - Pipe valida body → executa o Zod schema do DTO (ou rejeita com 400).
- Controller delega → cria
AccessContexte chama o handler. - Interceptor abre transação → todas as operações de banco participam dela.
- Handler verifica permissão → chama
ensureCanCreate(ou lança 403). - Handler cria entidade →
Campus.create()valida com Zod e gera UUID v7. - Handler persiste → chama o repositório (que internamente usa TypeORM).
- Interceptor faz commit → se tudo deu certo, commit. Se houve erro, rollback.
- Resposta retorna → 201 Created com a entidade criada.
Regra de comunicação: cada camada só conhece a camada imediatamente abaixo dela (via interfaces). A apresentação não sabe que o banco é PostgreSQL. O handler não sabe que o repositório usa TypeORM. O domínio não sabe que existe NestJS.
Para entender como as camadas se conectam na prática, veja o caminho completo de uma requisição HTTP:
sequenceDiagram
participant C as Cliente
participant CT as Controller / Resolver
participant H as Command / Query Handler
participant PC as Permission Checker
participant E as Entidade de Domínio
participant R as Repositório (TypeORM)
participant DB as PostgreSQL
C->>CT: POST /api/campi (com Bearer token)
CT->>CT: Valida DTO (Zod) e extrai AccessContext
CT->>H: execute(accessContext, dto)
H->>PC: ensureCanCreate(accessContext, { dto })
PC-->>H: OK (ou ForbiddenError)
H->>E: Campus.create(input)
E->>E: Valida com Zod, gera UUID v7
H->>R: create(campus)
R->>DB: INSERT INTO campus ...
DB-->>R: OK
R-->>H: { id }
H-->>CT: CampusFindOneQueryResult
CT-->>C: 201 Created
Esse fluxo se repete para todos os módulos. Queries (FindById, FindAll) seguem o mesmo padrão, mas sem escrita no banco. Atualmente queries não verificam permissões, porém está no roadmap a adição de filtragem de resultados com base nas permissões do usuário.
management-service/
├── .devcontainer/ # Configuração do Dev Container (VS Code / WebStorm)
├── .docker/ # Containerfile e compose.yml
├── .deploy/ # Scripts e values de deploy (Helm/Kubernetes)
├── .github/workflows/ # Pipelines de CI/CD
├── src/ # Código-fonte principal
│ ├── domain/ # Camada de domínio (entidades, abstrações, erros)
│ ├── application/ # Camada de aplicação (handlers, autorização, paginação)
│ ├── infrastructure.*/ # Adapters de infraestrutura (um por concern)
│ │ ├── infrastructure.config/ # Variáveis de ambiente e opções de runtime
│ │ ├── infrastructure.database/ # TypeORM, migrações, paginação
│ │ ├── infrastructure.graphql/ # Apollo Server, DTOs GraphQL
│ │ ├── infrastructure.identity-provider/ # Keycloak, OIDC, JWKS
│ │ ├── infrastructure.authorization/ # Implementações de permissão
│ │ ├── infrastructure.logging/ # Correlation ID, performance hooks
│ │ ├── infrastructure.message-broker/ # RabbitMQ via Rascal
│ │ ├── infrastructure.storage/ # Armazenamento de arquivos (filesystem)
│ │ ├── infrastructure.timetable-generator/ # Contratos de geração de horários
│ │ └── infrastructure.dependency-injection/# Configuração de DI do NestJS
│ ├── modules/ # Módulos de feature (um por entidade/conceito)
│ ├── server/ # Bootstrap do NestJS, filtros, interceptors, auth
│ ├── shared/ # Mappers, validação, decorators compartilhados
│ ├── utils/ # Utilitários puros (datas, helpers)
│ ├── commands/ # Scripts CLI (dev, test, migrations, etc.)
│ └── test/ # Helpers de teste (mocks, factories)
├── justfile # Receitas do task runner just
└── .env.example # Template de variáveis de ambiente
Cada módulo segue a mesma estrutura hexagonal interna:
modules/<grupo>/<nome-do-modulo>/
├── domain/
│ ├── authorization/ # Contrato de permissões (IPermissionChecker)
│ ├── commands/ # Definições de commands e interfaces de handlers
│ ├── queries/ # Definições de queries, schemas e result types
│ ├── repositories/ # Contratos de repositório (Symbol + type)
│ └── shared/ # Utilitários de domínio, input refs
├── application/
│ ├── authorization/ # Implementação do permission checker
│ ├── commands/ # Command handlers + testes
│ └── queries/ # Query handlers + testes
├── infrastructure.database/
│ └── typeorm/ # Entidades TypeORM
├── presentation.rest/ # Controllers REST, DTOs, mappers (Swagger)
└── presentation.graphql/ # Resolvers GraphQL, DTOs, mappers
Módulos organizados por área de negócio (38 módulos no total):
| Área | Descrição | Módulos |
|---|---|---|
| Acesso | Gestão de usuários, autenticação, perfis e notificações | usuario, autenticacao, notificacao, perfil |
| Ambientes | Estrutura física da instituição: campus, blocos e salas | campus, bloco, ambiente |
| Armazenamento | Upload e gerenciamento de arquivos e imagens | arquivo, imagem, imagem-arquivo |
| Ensino | Estrutura acadêmica: cursos, disciplinas, turmas, diários e ofertas de formação | curso, disciplina, modalidade, nivel-formacao, oferta-formacao, oferta-formacao-periodo, oferta-formacao-periodo-etapa, turma, diario |
| Estágio | Gestão de estágios, empresas parceiras e estagiários | empresa, estagiario, estagio, responsavel-empresa |
| Horários | Calendários letivos, agendamentos e geração automática de horários | calendario-letivo, calendario-agendamento, calendario-agendamento-ambiente, calendario-agendamento-calendario-letivo, calendario-agendamento-diario, calendario-agendamento-modalidade, calendario-agendamento-oferta-formacao, calendario-agendamento-professor, calendario-agendamento-turma, gerar-horario, gerar-horario-calendario-letivo, gerar-horario-oferta-formacao, horario-aula, horario-aula-configuracao, horario-consulta, horario-edicao, relatorio, turma-horario-aula |
| Localidades | Estados, cidades e endereços (dados IBGE) | estado, cidade, endereco |
As principais entidades e seus relacionamentos (baseado nas entidades TypeORM reais em src/modules/*/infrastructure.database/typeorm/):
erDiagram
Estado ||--o{ Cidade : "contém"
Cidade ||--o{ Endereco : "localiza"
Endereco ||--o{ Campus : "endereça"
Campus ||--o{ Bloco : "contém"
Bloco ||--o{ Ambiente : "contém"
Campus ||--o{ Perfil : "vincula"
Usuario ||--o{ Perfil : "possui"
Usuario ||--o{ Notificacao : "recebe"
Modalidade ||--o{ OfertaFormacao : "define tipo"
OfertaFormacao ||--o{ OfertaFormacaoNivelFormacao : "associa"
NivelFormacao ||--o{ OfertaFormacaoNivelFormacao : "associa"
OfertaFormacao ||--o{ OfertaFormacaoPeriodo : "contém períodos"
OfertaFormacaoPeriodo ||--o{ OfertaFormacaoPeriodoEtapa : "contém etapas"
Curso ||--o{ Turma : "oferece"
Ambiente }o--o| Turma : "ambiente padrão"
Turma ||--o{ Diario : "possui"
Disciplina ||--o{ Diario : "vincula"
CalendarioLetivo ||--o{ Diario : "vincula"
Diario ||--o{ DiarioProfessor : "associa professores"
Usuario ||--o{ DiarioProfessor : "leciona"
Diario ||--o{ DiarioPreferenciaAgrupamento : "configura"
HorarioAulaConfiguracao ||--o{ HorarioAula : "define"
Turma ||--o{ TurmaHorarioAula : "associa"
HorarioAula ||--o{ TurmaHorarioAula : "associa"
Empresa ||--o{ ResponsavelEmpresa : "possui"
Empresa ||--o{ Estagio : "oferece"
Estagiario ||--o{ Estagio : "participa"
Estagio ||--o{ HorarioEstagio : "define horários"
Imagem ||--o{ ImagemArquivo : "variações"
Arquivo ||--o{ ImagemArquivo : "armazena"
Nota: este diagrama mostra os relacionamentos principais. Entidades de agendamento de calendário (
calendario-agendamento-*) e geração de horários (gerar-horario-*) possuem tabelas junction adicionais não representadas para manter a legibilidade.
Agora que você entende a arquitetura em alto nível (as camadas e como elas se comunicam), esta seção mergulha nos padrões de código concretos — as "peças de Lego" que se repetem em todos os módulos. Se você vai contribuir com código, esses padrões são o que você vai encontrar e reproduzir no dia a dia.
Uma entidade de domínio é uma classe TypeScript que representa um conceito do mundo real (como um Campus, uma Turma ou um Diário). Diferente de uma classe comum, ela protege seus dados: você não cria uma instância diretamente com new Campus() — em vez disso, usa métodos especiais chamados factory methods (create para novos registros, load para reconstituir do banco, update para modificar).
Toda entidade segue o mesmo padrão: constructor privado (só a própria classe pode se instanciar), factory methods estáticos e validação Zod em cada operação.
graph TD
subgraph "Campus.create — nova entidade"
C1["dados brutos (unknown)"]
C2["zodValidate com CampusCreateSchema"]
C3["generateUuidV7 — gera id"]
C4["getNowISO — gera timestamps"]
C5["Instância Campus pronta"]
C1 --> C2 --> C3 --> C4 --> C5
end
subgraph "Campus.load — reconstruir do banco"
L1["dados do banco"]
L2["zodValidate com CampusSchema completo"]
L3["Instância Campus reconstituída"]
L1 --> L2 --> L3
end
subgraph "campus.update — atualização parcial"
U1["dados parciais"]
U2["zodValidate com CampusUpdateSchema"]
U3["Aplica campos presentes"]
U4["zodValidate com CampusSchema completo\n(rede de segurança final)"]
U5["Instância Campus atualizada"]
U1 --> U2 --> U3 --> U4 --> U5
end
C5 -- "repository.create" --> DB["PostgreSQL"]
L3 -- "já existia no banco" --> DB
U5 -- "repository.update" --> DB
style C5 fill:#50b86c,stroke:#3a8a50,color:#fff
style L3 fill:#4a90d9,stroke:#2c5f8a,color:#fff
style U5 fill:#e8a838,stroke:#b07c1e,color:#fff
style DB fill:#336791,stroke:#1e3d5c,color:#fff
// src/modules/ambientes/campus/domain/campus.ts
import type { z } from "zod";
import type { IdUuid, ScalarDateTimeString } from "@/domain/abstractions/scalars";
import { generateUuidV7 } from "@/domain/entities/utils/generate-uuid-v7";
import { zodValidate } from "@/shared/validation/index";
import { getNowISO } from "@/utils/date";
import { CampusCreateSchema, CampusSchema, CampusUpdateSchema } from "./campus.schemas";
export type ICampus = z.infer<typeof CampusSchema>;
export class Campus {
static readonly entityName = "Campus";
id!: IdUuid;
nomeFantasia!: string;
razaoSocial!: string;
apelido!: string;
cnpj!: string;
endereco!: ICampus["endereco"];
dateCreated!: ScalarDateTimeString;
dateUpdated!: ScalarDateTimeString;
dateDeleted!: ScalarDateTimeString | null;
private constructor() {}
static create(dados: unknown): Campus {
const parsed = zodValidate(Campus.entityName, CampusCreateSchema, dados);
const instance = new Campus();
instance.id = generateUuidV7();
instance.nomeFantasia = parsed.nomeFantasia;
instance.razaoSocial = parsed.razaoSocial;
instance.apelido = parsed.apelido;
instance.cnpj = parsed.cnpj;
instance.dateCreated = getNowISO();
instance.dateUpdated = getNowISO();
instance.dateDeleted = null;
return instance;
}
static load(dados: unknown): Campus {
const parsed = zodValidate(Campus.entityName, CampusSchema, dados);
const instance = new Campus();
// Atribui todos os campos do parsed
instance.id = parsed.id;
instance.nomeFantasia = parsed.nomeFantasia;
// ...
return instance;
}
update(dados: unknown): void {
const parsed = zodValidate(Campus.entityName, CampusUpdateSchema, dados);
if (parsed.nomeFantasia !== undefined) this.nomeFantasia = parsed.nomeFantasia;
if (parsed.razaoSocial !== undefined) this.razaoSocial = parsed.razaoSocial;
// ... demais campos opcionais
this.dateUpdated = getNowISO();
zodValidate(Campus.entityName, CampusSchema, this); // Validação final do estado completo
}
}Padrões:
create()— recebe dados brutos (unknown), valida comCampusCreateSchema, gera UUID v7 e datas. Usado para novas entidades.load()— reconstrói uma entidade a partir de dados existentes (ex.: do banco). Valida com o schema completo.update()— aplica mudanças parciais. Ao final, revalida o estado completo da entidade para garantir consistência.- Exceção:
EstadoeCidadeaceitamidnocreate(códigos IBGE).
Cada entidade define seus schemas em um arquivo *.schemas.ts. Eles são a fonte única de verdade para a forma dos dados:
graph TD
BASE["CampusSchema\n(schema completo)\n{id, nomeFantasia, razaoSocial,\ncnpj, endereco, dateCreated...}"]
BASE --> CREATE["CampusCreateSchema\n= CampusSchema sem id e datas\n{nomeFantasia, razaoSocial,\ncnpj, endereco}"]
BASE --> UPDATE["CampusUpdateSchema\n= CampusCreateSchema.partial()\n{nomeFantasia?, razaoSocial?,\ncnpj?, endereco?}"]
CREATE --> FACTORY["Campus.create()\nzodValidate(CampusCreateSchema)"]
UPDATE --> UPDATE_M["campus.update()\nzodValidate(CampusUpdateSchema)"]
BASE --> LOAD["Campus.load()\nzodValidate(CampusSchema)"]
BASE --> REVALIDATE["campus.update() final\nzodValidate(CampusSchema)\n(rede de segurança)"]
style BASE fill:#e8a838,stroke:#b07c1e,color:#fff
style CREATE fill:#4a90d9,stroke:#2c5f8a,color:#fff
style UPDATE fill:#50b86c,stroke:#3a8a50,color:#fff
// src/modules/ambientes/campus/domain/campus.schemas.ts
import { z } from "zod";
import { datedSchema, uuidSchema } from "@/shared/validation/schemas";
import { CampusFields } from "./campus.fields";
export const CampusSchema = z.object({
id: uuidSchema,
nomeFantasia: CampusFields.nomeFantasia.schema,
razaoSocial: CampusFields.razaoSocial.schema,
apelido: CampusFields.apelido.schema,
cnpj: CampusFields.cnpj.schema,
endereco: z.object({ id: uuidSchema, /* ... */ }).passthrough(),
}).merge(datedSchema);
export const CampusCreateSchema = z.object({
nomeFantasia: CampusFields.nomeFantasia.schema,
razaoSocial: CampusFields.razaoSocial.schema,
apelido: CampusFields.apelido.schema,
cnpj: CampusFields.cnpj.schema,
endereco: CampusEnderecoRefSchema,
});
export const CampusUpdateSchema = z.object({
nomeFantasia: CampusFields.nomeFantasia.schema.optional(),
razaoSocial: CampusFields.razaoSocial.schema.optional(),
apelido: CampusFields.apelido.schema.optional(),
cnpj: CampusFields.cnpj.schema.optional(),
endereco: CampusEnderecoRefSchema.optional(),
});Convenção:
Schema— schema completo da entidade (com id, datas).CreateSchema— sem id e datas (gerados automaticamente).UpdateSchema— todos os campos opcionais.- Os schemas dos campos vêm do
CampusFields(FieldMetadata) — garantindo que validação, Swagger e GraphQL compartilham a mesma definição.
A classe FieldMetadata (em src/domain/abstractions/fields/field-metadata.ts) define metadados de cada campo de uma entidade uma única vez, e esses metadados são reutilizados automaticamente em Swagger, GraphQL e validação:
graph TD
FM["CampusFields.nomeFantasia\n(FieldMetadata)\n{description, schema,\nnullable, defaultValue}"]
FM --> ZOD[".schema\nz.string().min(1)\n→ validação Zod"]
FM --> SWAGGER[".swaggerMetadata\n{description, required, type}\n→ Swagger docs"]
FM --> GQL[".gqlMetadata\n{description, nullable}\n→ @Field() GraphQL"]
subgraph "Consumidores"
ZOD --> SCHEMA["CampusCreateSchema\nz.object({ nomeFantasia: field.schema })"]
SWAGGER --> REST_DTO["CampusCreateInputRestDto\n@ApiProperty(field.swaggerMetadata)"]
GQL --> GQL_DTO["CampusFindOneOutputGraphQlDto\n@Field(() => String, field.gqlMetadata)"]
end
style FM fill:#e8a838,stroke:#b07c1e,color:#fff
style ZOD fill:#4a90d9,stroke:#2c5f8a,color:#fff
style SWAGGER fill:#50b86c,stroke:#3a8a50,color:#fff
style GQL fill:#e535ab,stroke:#b0297f,color:#fff
// src/modules/ambientes/campus/domain/campus.fields.ts
import { z } from "zod";
import { createFieldMetadata } from "@/domain/abstractions";
export const CampusFields = {
nomeFantasia: createFieldMetadata({
description: "Nome fantasia do campus",
schema: z.string().min(1, "nomeFantasia é obrigatório"),
}),
razaoSocial: createFieldMetadata({
description: "Razao social do campus",
schema: z.string().min(1, "razaoSocial é obrigatório"),
}),
cnpj: createFieldMetadata({
description: "CNPJ do campus",
schema: z.string().min(1, "cnpj é obrigatório")
.transform((val) => val.replace(/\D/g, ""))
.pipe(z.string().regex(/^\d{14}$/, "cnpj deve conter exatamente 14 dígitos")),
}),
// ...
};O FieldMetadata expõe .swaggerMetadata (para decorators REST) e .gqlMetadata (para decorators GraphQL) automaticamente a partir de description, nullable e defaultValue.
Repositórios são compostos de interfaces granulares via intersection types — em vez de uma interface monolítica, cada capacidade é definida separadamente (Interface Segregation Principle):
graph TD
subgraph "Interfaces granulares (src/domain/abstractions/repositories/)"
IC["IRepositoryCreate<T>\ncreate(data) → {id}"]
IU["IRepositoryUpdate<T>\nupdate(id, data) → void"]
ISD["IRepositorySoftDelete\nsoftDeleteById(id) → void"]
IFA["IRepositoryFindAll<T>\nfindAll(ac, dto, selection?)"]
IFB["IRepositoryFindById<T>\nfindById(ac, {id}, selection?)"]
IFBS["IRepositoryFindByIdSimple<T>\nfindByIdSimple(ac, id)"]
end
subgraph "Composição via intersection (&)"
CAMPUS_REPO["ICampusRepository =\nIRepositoryCreate & IRepositoryUpdate &\nIRepositorySoftDelete & IRepositoryFindAll &\nIRepositoryFindById & IRepositoryFindByIdSimple"]
end
IC & IU & ISD & IFA & IFB & IFBS --> CAMPUS_REPO
style CAMPUS_REPO fill:#e8a838,stroke:#b07c1e,color:#fff
style IC fill:#4a90d9,stroke:#2c5f8a,color:#fff
style IU fill:#4a90d9,stroke:#2c5f8a,color:#fff
// src/domain/abstractions/repositories/repository-create.interface.ts
export interface IRepositoryCreate<DomainData> {
create(data: Partial<PersistInput<DomainData>>): Promise<{ id: string | number }>;
}
// src/modules/ambientes/campus/domain/repositories/campus.repository.interface.ts
export const ICampusRepository = Symbol("ICampusRepository");
export type ICampusRepository = IRepositoryFindAll<CampusListQueryResult> &
IRepositoryFindById<CampusFindOneQueryResult> &
IRepositoryFindByIdSimple<CampusFindOneQueryResult> &
IRepositoryCreate<ICampus> &
IRepositoryUpdate<ICampus> &
IRepositorySoftDelete;Interfaces disponíveis (em src/domain/abstractions/repositories/):
IRepositoryCreate<T>—create(data)→{ id }IRepositoryUpdate<T>—update(id, data)→voidIRepositorySoftDelete—softDeleteById(id)→voidIRepositoryFindAll<T>—findAll(ac, dto, selection?)→TIRepositoryFindById<T>—findById(ac, { id }, selection?)→T | nullIRepositoryFindByIdSimple<T>—findByIdSimple(ac, id, selection?)→T | null
O type PersistInput<T> converte relações em referências { id } para desacoplar a persistência da forma completa da entidade.
Mappers são funções que traduzem dados de um formato para outro quando eles cruzam fronteiras entre camadas. Como a arquitetura hexagonal isola o domínio da infraestrutura, cada camada pode representar os mesmos dados de formas diferentes — por exemplo, o domínio armazena datas como strings ISO ("2025-06-15T10:30:00.000Z") enquanto o TypeORM usa objetos Date do JavaScript. O mapper é quem faz essa conversão.
Analogia: imagine que um hospital brasileiro recebe um paciente estrangeiro. O prontuário interno é em português, mas o paciente trouxe exames em inglês. O mapper é o tradutor que converte os exames para português (entrada) e traduz o diagnóstico de volta para inglês (saída) — sem alterar o conteúdo médico, apenas o formato.
O projeto possui mappers em duas camadas:
graph LR
subgraph "Apresentação (REST/GraphQL)"
DTO_IN["DTO de Entrada\n(CampusCreateInputRestDto)"]
DTO_OUT["DTO de Saída\n(CampusFindOneOutputRestDto)"]
PMAP["RestMapper / GraphqlMapper\n• toCreateInput(dto) → Command\n• toFindOneOutputDto(result) → DTO\n• toListInput(dto) → Query"]
end
subgraph "Infraestrutura (TypeORM)"
ENTITY["TypeORM Entity\n(CampusEntity)\ndatas: Date\nrelações: Relation<T>"]
IMAP["EntityDomainMapper\n• toDomainData(entity)\n• toPersistenceData(domain)\n• toOutputData(entity)"]
end
subgraph "Domínio"
DOMAIN["Entidade de Domínio\n(Campus)\ndatas: ISO string\nrelações: { id }"]
CMD["Command / Query Result"]
end
DTO_IN --> PMAP --> CMD
CMD --> DOMAIN
DOMAIN --> IMAP --> ENTITY
ENTITY --> IMAP --> DOMAIN
CMD --> PMAP --> DTO_OUT
style DOMAIN fill:#27ae60,stroke:#1e8449,color:#fff
style IMAP fill:#e67e22,stroke:#d35400,color:#fff
style PMAP fill:#3498db,stroke:#2980b9,color:#fff
Cada módulo define um mapper declarativo em infrastructure.database/typeorm/{nome}.mapper.ts usando o helper createEntityDomainMapper. Ele converte automaticamente entre os tipos do domínio (strings ISO, referências { id }) e os tipos do TypeORM (objetos Date, relações carregadas):
// src/modules/ambientes/campus/infrastructure.database/typeorm/campus.mapper.ts
import { createEntityDomainMapper } from "@/infrastructure.database/typeorm/helpers/entity-domain-mapper";
import type { ICampus } from "@/modules/ambientes/campus/domain/campus";
export const campusEntityDomainMapper = createEntityDomainMapper<ICampus, Record<string, unknown>>({
fields: [
"id", // campo direto — sem conversão
"nomeFantasia", // campo direto
"razaoSocial", // campo direto
"apelido", // campo direto
"cnpj", // campo direto
{ field: "endereco", type: "relation" }, // { id, logradouro, ... } → { id }
{ field: "dateCreated", type: "date" }, // Date ↔ ISO string
{ field: "dateUpdated", type: "date" }, // Date ↔ ISO string
{ field: "dateDeleted", type: "date" }, // Date | null ↔ ISO string | null
],
});Tipos de campo disponíveis:
| Tipo | Entity → Domain | Domain → Entity | Quando usar |
|---|---|---|---|
string (nome do campo) |
passthrough | passthrough | Campos com mesmo tipo em ambas as camadas |
"date" |
Date → "2025-06-15T10:30:00.000Z" |
ISO string → Date |
dateCreated, dateUpdated, dateDeleted |
"date-only" |
Date → "2025-06-15" |
"YYYY-MM-DD" → Date |
dataNascimento e similares |
"relation" |
{ id, nome, ... } → { id } |
passthrough | Quando o domínio armazena apenas a referência ({ id }) |
"relation-loaded" |
passthrough | { id, nome, ... } → { id } |
Quando o domínio armazena o objeto completo (ex: cidade.estado) |
{ forward, reverse } |
função custom | função custom | Casos especiais |
O mapper é interno à infraestrutura — handlers e controllers nunca o acessam diretamente. O repositório usa toPersistenceData() para converter dados do domínio antes de salvar:
// Dentro do repositório (infraestrutura)
create(data: Record<string, unknown>) {
const entityData = campusEntityDomainMapper.toPersistenceData(data);
return typeormCreate(this.appTypeormConnection, CampusEntity, entityData);
}Para módulos com campos computados no output (ex: ativo = !dateDeleted), o mapper aceita uma config output adicional:
// src/modules/estagio/empresa/infrastructure.database/typeorm/empresa.mapper.ts
export const empresaEntityDomainMapper = createEntityDomainMapper<...>({
fields: [ /* ... campos bidirecionais ... */ ],
output: [
"id", "razaoSocial", "nomeFantasia", /* ... */
["dateCreated", "dateCreated", dateToISO],
["dateDeleted", "ativo", (v) => !v], // campo computado
],
});Os mappers de apresentação ficam em presentation.rest/{nome}.rest.mapper.ts e presentation.graphql/{nome}.graphql.mapper.ts. Eles usam os helpers de @/shared/mapping:
createMapping(fields)— mapeia campos entre objetos (suporta dot notation, transforms)createListInputMapper(QueryClass, filterKeys)— mapeia paginação, busca, filtros (REST)createListOutputMapper(DtoClass, itemMapper)— mapeia listas paginadasmapFilterCase("filter.estado.id")— convertefilterEstadoId(GraphQL camelCase) →"filter.estado.id"(dot notation)
// REST — filtros usam dot notation diretamente
static toListInput = createListInputMapper(CidadeListQuery, [
"filter.id", "filter.estado.id", "filter.estado.nome",
]);
// GraphQL — converte camelCase para dot notation
const listInputMapping = createMapping([
"page", "limit", "search", "sortBy",
mapFilterCase("filter.id"), // filterId → filter.id
mapFilterCase("filter.estado.id"), // filterEstadoId → filter.estado.id
]);Para ir mais fundo: os transforms reutilizáveis ficam em
src/shared/mapping/transforms.ts(dateToISO,isoToDate,normalizeRelationRef, etc.). O helpercreateBidirectionalMappingemsrc/shared/mapping/field-mapper.tspermite definir um mapeamento uma vez e obter ambas as direções automaticamente — é a base docreateEntityDomainMapper.
Handlers seguem contratos genéricos definidos em src/domain/abstractions/operations/cqrs/:
graph TD
subgraph "Command Handler (escrita)"
CMD_IN["execute(accessContext, dto)"]
CMD_PERM["1. ensureCanCreate(ac)"]
CMD_ENT["2. Campus.create(dto)"]
CMD_REPO["3. repository.create(campus)"]
CMD_FIND["4. repository.findById(id)"]
CMD_OUT["5. retorna CampusFindOneQueryResult"]
CMD_IN --> CMD_PERM --> CMD_ENT --> CMD_REPO --> CMD_FIND --> CMD_OUT
end
subgraph "Query Handler (leitura)"
QRY_IN["execute(accessContext, {id}, selection)"]
QRY_REPO["1. repository.findById(ac, {id}, selection)"]
QRY_OUT["2. retorna resultado ou null"]
QRY_IN --> QRY_REPO --> QRY_OUT
end
style CMD_IN fill:#7b68ee,stroke:#5a4db0,color:#fff
style CMD_PERM fill:#e74c3c,stroke:#c0392b,color:#fff
style CMD_ENT fill:#e8a838,stroke:#b07c1e,color:#fff
style CMD_REPO fill:#50b86c,stroke:#3a8a50,color:#fff
style QRY_IN fill:#4a90d9,stroke:#2c5f8a,color:#fff
// Contrato genérico
export interface ICommandHandler<TCommand, TResult = void> {
execute(accessContext: IAccessContext | null, command: TCommand): Promise<TResult>;
}
// Implementação real — src/modules/ambientes/campus/application/commands/campus-create.command.handler.ts
@DeclareImplementation()
export class CampusCreateCommandHandlerImpl implements ICampusCreateCommandHandler {
constructor(
@DeclareDependency(ICampusRepository) private readonly repository: ICampusRepository,
@DeclareDependency(ICampusPermissionChecker) private readonly permissionChecker: ICampusPermissionChecker,
@DeclareDependency(IEnderecoCreateOrUpdateCommandHandler) private readonly enderecoCreateOrUpdateHandler: IEnderecoCreateOrUpdateCommandHandler,
) {}
async execute(accessContext: IAccessContext | null, dto: CampusCreateCommand): Promise<CampusFindOneQueryResult> {
await this.permissionChecker.ensureCanCreate(accessContext, { dto });
const endereco = await this.enderecoCreateOrUpdateHandler.execute(null, { id: null, dto: dto.endereco });
const domain = Campus.create({ nomeFantasia: dto.nomeFantasia, /* ... */ });
const { id } = await this.repository.create({ ...domain, endereco: { id: endereco.id as string } });
const result = await this.repository.findById(accessContext, { id });
ensureExists(result, Campus.entityName, id);
return result;
}
}Fluxo padrão de um command handler: verificar permissão → criar/atualizar entidade de domínio → persistir via repositório → retornar resultado.
Cada módulo implementa IPermissionChecker com o padrão "throw on deny" — se o usuário não tem permissão, uma exceção é lançada:
graph TD
HANDLER["Handler.execute(accessContext, dto)"]
PC["PermissionChecker\n.ensureCanCreate(ac, {dto})"]
HANDLER --> PC
PC --> |"usuário autorizado"| CONTINUE["Continua execução\n(Campus.create, repository.create)"]
PC -.-> |"sem permissão"| THROW["throw ForbiddenError\n→ 403 Forbidden"]
subgraph "Exemplo: CampusPermissionChecker"
PC_IMPL["ensureCanCreate(): void\n(no-op — permite tudo)\n\nQuando implementado:\nif (!ac.isSuperUser) throw ForbiddenError"]
end
PC --- PC_IMPL
style HANDLER fill:#7b68ee,stroke:#5a4db0,color:#fff
style CONTINUE fill:#50b86c,stroke:#3a8a50,color:#fff
style THROW fill:#e74c3c,stroke:#c0392b,color:#fff
// Contrato — src/domain/abstractions/permission-checker.interface.ts
export interface IPermissionChecker {
ensureCanCreate(ac: IAccessContext | null, payload: { dto: unknown }): Promise<void>;
ensureCanUpdate(ac: IAccessContext | null, payload: { dto: unknown }, id: string): Promise<void>;
ensureCanDelete(ac: IAccessContext | null, payload: { dto: unknown }, id: string): Promise<void>;
}As implementações atuais são no-ops (não verificam nada) — isso é intencional e não deve ser sinalizado como anti-pattern. Quando implementadas, lançam
ForbiddenError.
Decorators customizados em src/domain/dependency-injection/ que abstraem o NestJS:
graph LR
subgraph "Domínio (define o que precisa)"
SYM["Symbol('ICampusRepository')\n(token de injeção)"]
TYPE["type ICampusRepository =\n IRepositoryCreate &\n ..."]
end
subgraph "Infraestrutura (implementa)"
IMPL["@DeclareImplementation()\nclass CampusTypeormRepository"]
end
subgraph "Aplicação (consome)"
HANDLER["constructor(\n @DeclareDependency(\n ICampusRepository\n )\n private repo:\n ICampusRepository\n)"]
end
subgraph "NestJS (resolve em runtime)"
DI["Container de DI\nresolve Symbol →\nImplementação"]
end
SYM --> DI
IMPL -- "registra como provider\ndo Symbol" --> DI
DI -- "injeta implementação\nno constructor" --> HANDLER
style DI fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style SYM fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style TYPE fill:none,stroke:none,text-align:left
style IMPL fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style HANDLER fill:none,stroke:none,text-align:left
// src/domain/dependency-injection/declare-dependency.ts
export const DeclareDependency = (token: any): ParameterDecorator => {
const injectDecorator = NestjsInject(token);
return (target, propertyKey, parameterIndex) => {
return injectDecorator(target, propertyKey!, parameterIndex);
};
};
// src/domain/dependency-injection/declare-implementation.ts
export const DeclareImplementation = (): ClassDecorator => {
return Injectable();
};DeclareDependency é um wrapper para @Inject() do NestJS. DeclareImplementation é um wrapper para @Injectable(). O acoplamento domínio ↔ NestJS é aceito pragmaticamente.
Um scalar (escalar) neste contexto é um tipo simples que representa um único valor (como uma string ou um número). O problema é que string é genérico demais — um id, um nome e uma data são todos string, mas representam coisas completamente diferentes. Scalars semânticos são type aliases (apelidos de tipo) que adicionam significado ao tipo primitivo, para que o TypeScript te avise se você tentar usar um no lugar do outro.
Eles ficam em src/domain/abstractions/scalars/:
graph LR
subgraph "Sem scalars (ambíguo)"
S1["id: string"]
S2["nome: string"]
S3["dateCreated: string"]
S4["codigoIbge: number"]
end
subgraph "Com scalars (semântico)"
T1["id: IdUuid"]
T2["nome: string"]
T3["dateCreated:\nScalarDateTimeString"]
T4["codigoIbge: IdNumeric"]
end
S1 -.-> |"TypeScript permite\nconfundir id com nome\n(ambos string)"| WARN["Bug potencial"]
T1 -.-> |"TypeScript sinaliza\nse trocar IdUuid por string"| SAFE["Type safety"]
style S1 fill:none,stroke:none,text-align:left
style S2 fill:none,stroke:none,text-align:left
style S3 fill:none,stroke:none,text-align:left
style S4 fill:none,stroke:none,text-align:left
style T1 fill:none,stroke:none,text-align:left
style T2 fill:none,stroke:none,text-align:left
style T3 fill:none,stroke:none,text-align:left
style T4 fill:none,stroke:none,text-align:left
style WARN fill:#e74c3c,stroke:#c0392b,color:#fff,text-align:left
style SAFE fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
| Scalar | Tipo base | Propósito |
|---|---|---|
IdUuid |
string |
Identificador UUID (evita confundir com strings genéricas) |
IdNumeric |
number |
Identificador numérico (IBGE codes) |
ScalarDateTimeString |
string |
Data/hora em formato ISO string |
ScalarDate |
string |
Data (sem hora) em formato ISO string |
O mecanismo de transações automáticas envolve três peças que cooperam para que repositórios participem da mesma transação sem saber disso:
graph TD
subgraph "Peça 1: TransactionInterceptor"
TI["Abre transação\nantes do handler"]
end
subgraph "Peça 2: AsyncLocalStorage"
ALS["transactionStorage\nArmazena EntityManager\nno escopo da requisição"]
end
subgraph "Peça 3: ConnectionProxy"
CP["AppTypeormConnectionProxy\nintercepta getRepository()"]
CP --> |"EntityManager ativo?"| YES["Usa EntityManager\n(transacional)"]
CP --> |"sem EntityManager"| NO["Usa DataSource\n(global)"]
end
TI -- "armazena EntityManager" --> ALS
ALS -- "getActiveEntityManager()" --> CP
style TI fill:#4a90d9,stroke:#2c5f8a,color:#fff
style ALS fill:#e8a838,stroke:#b07c1e,color:#fff
style CP fill:#50b86c,stroke:#3a8a50,color:#fff
O mecanismo envolve três peças:
-
TransactionInterceptor(src/server/nest/interceptors/transaction.interceptor.ts) — interceptor global que abre uma transação viaappTypeormConnection.transaction()antes de cada handler. -
transactionStorage(src/infrastructure.database/typeorm/connection/transaction-storage.ts) —AsyncLocalStorage<EntityManager>que propaga oEntityManagertransacional por toda a call stack:
export const transactionStorage = new AsyncLocalStorage<EntityManager>();
export function getActiveEntityManager(): EntityManager | undefined {
return transactionStorage.getStore();
}AppTypeormConnectionProxy(src/infrastructure.database/typeorm/connection/app-typeorm-connection.proxy.ts) — proxy que interceptagetRepository(): se existe umEntityManagerativo noAsyncLocalStorage, usa-o; caso contrário, usa oDataSourceglobal:
getRepository<Entity extends ObjectLiteral>(target: EntityTarget<Entity>): Repository<Entity> {
const activeManager = getActiveEntityManager();
if (activeManager) return activeManager.getRepository(target);
return this.dataSource.getRepository(target);
}O pipe global (src/shared/validation/zod-global-validation.pipe.ts) valida automaticamente DTOs que possuem static schema:
sequenceDiagram
participant C as Cliente
participant P as ZodGlobalValidationPipe
participant DTO as CampusCreateInputRestDto
participant CTRL as Controller
C->>P: POST /api/campi { nomeFantasia: "" }
P->>DTO: Tem static schema?
DTO-->>P: Sim → CampusCreateSchema
P->>P: schema.safeParse({ nomeFantasia: "" })
alt Válido
P-->>CTRL: dados parseados (tipados)
else Inválido
P-->>C: 400 Bad Request\n[{field: "nomeFantasia", message: "nomeFantasia é obrigatório"}]
end
@Injectable()
export class ZodGlobalValidationPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata) {
const metatype = metadata.metatype;
if (!hasSchema(metatype)) return value; // Se o DTO não tem schema, passa direto
const result = metatype.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException(
result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
rule: issue.code,
})),
);
}
return result.data;
}
}O filtro global (src/server/nest/filters/application-error.filter.ts) captura erros de domínio e aplicação e os traduz para respostas HTTP padronizadas:
graph LR
subgraph "Erros de domínio / aplicação"
E1["ResourceNotFoundError"]
E2["ForbiddenError"]
E3["ValidationError"]
E4["ConflictError"]
E5["EntityValidationError"]
end
FILTER["ApplicationErrorFilter\n+ error-http.mapper.ts"]
subgraph "Respostas HTTP"
H1["404 Not Found"]
H2["403 Forbidden"]
H3["422 Unprocessable"]
H4["409 Conflict"]
H5["422 Unprocessable\n(detalhes por campo)"]
end
E1 --> FILTER --> H1
E2 --> FILTER --> H2
E3 --> FILTER --> H3
E4 --> FILTER --> H4
E5 --> FILTER --> H5
style FILTER fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style H1 fill:#e74c3c,stroke:#c0392b,color:#fff,text-align:left
style H2 fill:#e74c3c,stroke:#c0392b,color:#fff,text-align:left
@Catch(ApplicationError, DomainError)
export class ApplicationErrorFilter implements ExceptionFilter {
catch(exception: ApplicationError | DomainError, host: ArgumentsHost) {
const errorResponse = buildHttpErrorResponse(exception, request.url);
response.status(errorResponse.statusCode).json(errorResponse);
}
}Mapeamento de erros (src/server/nest/filters/error-http.mapper.ts):
| Código do erro | HTTP Status |
|---|---|
APP.RESOURCE_NOT_FOUND |
404 |
APP.FORBIDDEN |
403 |
APP.UNAUTHORIZED |
401 |
APP.VALIDATION |
422 |
APP.CONFLICT |
409 |
APP.INTERNAL |
500 |
APP.SERVICE_UNAVAILABLE |
503 |
DOMAIN.ENTITY_VALIDATION |
422 |
DOMAIN.BUSINESS_RULE_VIOLATION |
422 |
DOMAIN.INVALID_STATE |
422 |
DOMAIN.INVARIANT_VIOLATION |
422 |
A paginação usa a biblioteca nestjs-paginate v12 com um adapter próprio em src/infrastructure.database/pagination/:
sequenceDiagram
participant C as Cliente
participant CTRL as Controller
participant H as ListQueryHandler
participant R as Repository
participant NP as nestjs-paginate
C->>CTRL: GET /api/campi?page=2&limit=10&search=IFRO&sortBy=nomeFantasia:ASC
CTRL->>H: execute(ac, paginateQuery)
H->>R: findAll(ac, paginateQuery, selection)
R->>NP: NestJsPaginateAdapter.paginate(repo, dto, config)
NP->>NP: Aplica filtros, busca, ordenação
NP-->>R: { data: Campus[], meta: { totalItems, currentPage, ... } }
R-->>C: { data: [...], meta: { totalItems: 47, currentPage: 2, itemsPerPage: 10 } }
// No repositório TypeORM
NestJsPaginateAdapter.paginate(repo, dto, paginateConfig({
sortableColumns: ["id", "nomeFantasia", "dateCreated"],
searchableColumns: ["nomeFantasia", "razaoSocial"],
filterableColumns: { "campus.id": [FilterOperator.EQ] },
}));Configuração padrão (src/infrastructure.database/pagination/config/paginate-config.ts): maxLimit: 100, defaultLimit: 20, multiWordSearch: true.
As seções a seguir cobrem tópicos especializados — leia conforme precisar trabalhar com cada área.
GraphQL é uma linguagem de consulta alternativa ao REST. A diferença principal: no REST, o servidor decide quais campos retornar; no GraphQL, o cliente diz exatamente quais campos quer e recebe apenas esses. É como a diferença entre um buffet (REST — pega tudo) e um pedido à la carte (GraphQL — escolhe item por item).
A API GraphQL usa Apollo Server v5 com abordagem code-first (o schema GraphQL é gerado automaticamente a partir de classes TypeScript decoradas com @ObjectType() e @Field(), em vez de ser escrito manualmente em arquivos .graphql).
graph TD
CLIENT["Cliente\n(front-end)"]
subgraph "Apollo Server v5"
GQL_EP["Endpoint /api/graphql"]
CACHE["LRU Cache\n100 MB / 5 min TTL"]
SCHEMA["Schema gerado\n(code-first)"]
end
subgraph "Resolvers (presentation.graphql/)"
RES_C["CampusResolver\n@Query campusFindOne\n@Query campusFindAll\n@Mutation campusCreate\n@Mutation campusUpdate\n@Mutation campusDelete"]
RES_T["TurmaResolver"]
RES_D["DiarioResolver"]
RES_N["... (18 resolvers)"]
end
subgraph "Handlers (aplicação)"
H_FIND["FindOneQueryHandler"]
H_LIST["ListQueryHandler"]
H_CREATE["CreateCommandHandler"]
end
CLIENT -- "query / mutation" --> GQL_EP
GQL_EP --> SCHEMA
GQL_EP --> CACHE
SCHEMA --> RES_C & RES_T & RES_D & RES_N
RES_C --> H_FIND & H_LIST & H_CREATE
style CLIENT fill:#4a90d9,stroke:#2c5f8a,color:#fff
style GQL_EP fill:#e535ab,stroke:#b0297f,color:#fff
style SCHEMA fill:#e535ab,stroke:#b0297f,color:#fff
style RES_C fill:#7b68ee,stroke:#5a4db0,color:#fff
graph TD
subgraph "REST (presentation.rest/)"
REST_REQ["POST /api/campi\n+ JSON body"]
REST_CTRL["CampusRestController"]
REST_DTO["CampusCreateInputRestDto\n(static schema)"]
REST_MAP["CampusRestMapper"]
end
subgraph "GraphQL (presentation.graphql/)"
GQL_REQ["mutation {\n campusCreate(input: {...}) {\n id, nomeFantasia\n }\n}"]
GQL_RES["CampusResolver"]
GQL_DTO["CampusCreateInputGraphQlDto\n(@InputType + static schema)"]
GQL_MAP["CampusGraphqlMapper"]
end
subgraph "Compartilhado (aplicação + domínio)"
HANDLER["CampusCreateCommandHandler\n(mesma lógica)"]
PERM["PermissionChecker"]
ENT["Campus.create()"]
REPO["ICampusRepository"]
end
REST_REQ --> REST_CTRL --> REST_DTO --> REST_MAP --> HANDLER
GQL_REQ --> GQL_RES --> GQL_DTO --> GQL_MAP --> HANDLER
HANDLER --> PERM --> ENT --> REPO
style HANDLER fill:#7b68ee,stroke:#5a4db0,color:#fff
style REST_CTRL fill:#4a90d9,stroke:#2c5f8a,color:#fff
style GQL_RES fill:#e535ab,stroke:#b0297f,color:#fff
style ENT fill:#e8a838,stroke:#b07c1e,color:#fff
graph LR
subgraph "Código TypeScript"
OT["@ObjectType('Campus')\nclass CampusFindOneOutput\n GraphQlDto"]
F1["@Field(() => String)\nnomeFantasia!: string"]
F2["@Field(() => String)\nrazaoSocial!: string"]
end
OT --- F1
OT --- F2
OT --> NESTJS_GQL["NestJS GraphQL\n(autoSchemaFile: true)"]
NESTJS_GQL --> GQL_SCHEMA["Schema GraphQL gerado\ntype Campus {\n nomeFantasia: String!\n razaoSocial: String!\n}"]
style OT fill:#e535ab,stroke:#b0297f,color:#fff,text-align:left
style F1 fill:none,stroke:none,text-align:left
style F2 fill:none,stroke:none,text-align:left
style NESTJS_GQL fill:none,stroke:none,text-align:left
style GQL_SCHEMA fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
sequenceDiagram
participant C as Cliente
participant A as Apollo Server
participant R as CampusResolver
participant H as FindOneQueryHandler
participant DB as PostgreSQL
C->>A: query { campusFindOne(id: "uuid") { id, nomeFantasia } }
A->>A: Parse e valida query contra schema
A->>R: campusFindOne(id, info, accessContext)
R->>R: graphqlExtractSelection(info)\n→ ["id", "nomeFantasia"]
R->>H: execute(accessContext, {id}, selection)
H->>DB: SELECT id, nome_fantasia FROM campus WHERE id = $1
DB-->>H: { id, nomeFantasia }
H-->>R: CampusFindOneQueryResult
R-->>A: CampusFindOneOutputGraphQlDto
A-->>C: { data: { campusFindOne: { id: "...", nomeFantasia: "IFRO" } } }
Note over C: Recebe APENAS os campos pedidos
| Configuração | Valor |
|---|---|
| Endpoint | http://localhost:3701/api/graphql |
| Playground | GraphiQL habilitado em desenvolvimento |
| Introspection | habilitada |
| Cache | LRU em memória (100 MB, TTL de 5 minutos) |
| Schema | code-first (autoSchemaFile: true) |
| Number mode | integer (números são Int, não Float) |
Exemplo de query:
# Buscar um campus por ID — peça apenas os campos que precisa
query {
findById(id: "uuid-do-campus") {
id
nomeFantasia
razaoSocial
apelido
cnpj
}
}graph TD
subgraph "REST + GraphQL (18 módulos)"
A1["campus"] & A2["bloco"] & A3["ambiente"]
B1["usuario"] & B2["perfil"]
C1["curso"] & C2["disciplina"] & C3["turma"] & C4["diario"]
D1["modalidade"] & D2["nivel-formacao"] & D3["oferta-formacao"]
E1["estado"] & E2["cidade"] & E3["endereco"]
F1["calendario-letivo"] & F2["empresa"] & F3["imagem-arquivo"]
end
subgraph "Apenas REST (sem GraphQL)"
R1["autenticacao\n(login, refresh)"]
R2["arquivo\n(upload)"]
R3["estagiario\nestagio\nresponsavel-empresa"]
R4["gerar-horario\nhorario-edicao\nhorario-consulta"]
R5["relatorio\nnotificacao"]
end
style A1 fill:#e535ab,stroke:#b0297f,color:#fff
style R1 fill:#4a90d9,stroke:#2c5f8a,color:#fff
Compartilhamento de lógica: os resolvers GraphQL (em presentation.graphql/) reutilizam os mesmos command/query handlers da API REST. Isso significa que a lógica de negócio, validação e autorização são idênticas independentemente de a requisição vir via REST ou GraphQL.
Nota avançada: o projeto não usa DataLoader para resolver o problema N+1 do GraphQL — queries que buscam relações fazem JOINs no repositório TypeORM. A função
graphqlExtractSelection()(emsrc/infrastructure.graphql/graphql-selection.ts) extrai os campos solicitados da query GraphQL e os passa para o repositório, que faz SELECT apenas das colunas necessárias — otimizando a query SQL.
O projeto usa RabbitMQ como message broker, integrado via biblioteca Rascal v21 (wrapper AMQP).
Uso atual: comunicação assíncrona para geração de horários (timetable).
sequenceDiagram
participant MS as Management Service
participant RMQ as RabbitMQ
participant TG as Timetable Generator
MS->>RMQ: Publica requisição na fila (request)
RMQ->>TG: Entrega mensagem
TG->>TG: Processa geração de horários
TG->>RMQ: Publica resultado na fila (response)
RMQ->>MS: Entrega resposta
A aplicação publica uma mensagem de requisição na fila e consome a resposta quando o serviço gerador completa o processamento. Dois padrões são implementados em IMessageBrokerService (src/domain/abstractions/message-broker/):
- RPC (
publishTimetableRequest) — publica e espera resposta com timeout. - Fire-and-forget (
publishTimetableRequestFireAndForget) — publica sem esperar.
Filas configuráveis via variáveis de ambiente:
| Variável | Padrão |
|---|---|
MESSAGE_BROKER_QUEUE_TIMETABLE_REQUEST |
dev.timetable_generate.request |
MESSAGE_BROKER_QUEUE_TIMETABLE_RESPONSE |
dev.timetable_generate.response |
A UI de gerenciamento do RabbitMQ está disponível em http://localhost:15672 (usuário admin, senha admin).
Testes automatizados são programas que verificam se o código funciona como esperado. Quando você roda bun run test, esses programas executam cenários pré-definidos e reportam se algo quebrou.
O projeto usa Vitest v4 como framework de testes (Vitest é similar ao Jest, mas otimizado para projetos que usam Vite/Bun).
graph TD
subgraph "Testes unitários (*.spec.ts)"
UT["Handler / Entidade / Utilitário"]
MOCK_REPO["Mock de repositório\n(createMockCrudRepository)"]
MOCK_PC["Mock de permission checker\n(createMockPermissionChecker)"]
MOCK_AC["Mock de access context\n(createTestAccessContext)"]
UT --> MOCK_REPO & MOCK_PC & MOCK_AC
end
subgraph "Testes e2e (*.e2e-spec.ts)"
E2E["Requisição HTTP completa"]
E2E --> REAL_DB["PostgreSQL real"]
E2E --> REAL_APP["NestJS completo\n(pipes, guards, interceptors)"]
end
subgraph "Pirâmide de testes"
P1["Unitários\n(rápidos, isolados)"]
P2["E2E\n(lentos, integrados)"]
P1 --- P2
end
style UT fill:#4a90d9,stroke:#2c5f8a,color:#fff
style E2E fill:#50b86c,stroke:#3a8a50,color:#fff
style MOCK_REPO fill:#7b68ee,stroke:#5a4db0,color:#fff
| Tipo | Padrão de arquivo | O que testa |
|---|---|---|
| Unitário | **/*.spec.ts |
Lógica isolada de command/query handlers, entidades de domínio e utilitários — com mocks de repositório e serviços externos |
| End-to-end | **/*.e2e-spec.ts |
Fluxo completo de requisição HTTP, incluindo integração com banco de dados e serviços reais |
bun run test # Executar testes unitários uma vez
bun run test:watch # Modo watch — re-executa ao salvar arquivos
bun run test:cov # Com relatório de cobertura (provedor v8)
bun run test:e2e # Testes end-to-end
bun run test:debug # Com debugger (porta 9229)Mocks de repositório, factories e utilitários de teste ficam em src/test/helpers/:
| Helper | O que fornece |
|---|---|
createTestId() |
UUID v7 para testes |
createTestDate(offset?) |
Datas fixas ISO para testes determinísticos |
createTestRequestActor(overrides?) |
IRequestActor mock com dados padrão |
createTestAccessContext(actor?) |
IAccessContext completo para testes |
createTestSuperUserAccessContext() |
AccessContext com superuser |
createTestRef(id?) |
Referência { id } para relações |
createTestDatedFields(offset?) |
Campos dateCreated, dateUpdated, dateDeleted |
createMockCrudRepository() |
Repositório mock com todos os métodos (vi.fn()) |
createMockPermissionChecker() |
Permission checker mock (no-op por padrão) |
O Vitest está configurado em src/vitest.config.mts:
- Globals:
true(não precisa importardescribe,it,expect). - Path alias:
@/*→./(respeita tsconfig paths). - Bundling: Zod é bundled (
noExternal: ["zod"]).
O que é CI/CD? CI (Continuous Integration — Integração Contínua) é o processo automático de compilar e testar o código a cada push. CD (Continuous Deployment — Deploy Contínuo) é a publicação automática do sistema após a CI passar. Juntos, garantem que código novo seja validado e disponibilizado rapidamente.
O pipeline de CI/CD é definido em .github/workflows/build-deploy.dev.yml.
Triggers:
- Manual dispatch (workflow_dispatch)
- Push na branch
main(quando há mudanças emsrc/,.docker/,.github/workflows/ou.deploy/)
Concurrency: build-deploy-dev — apenas uma execução por vez.
graph LR
PUSH["Push na main\n(ou dispatch manual)"] --> CI
subgraph CI["CI — Build & Push"]
CHECKOUT["Checkout"] --> BUILDX["QEMU + Buildx\n(multi-arch)"]
BUILDX --> LOGIN["Login no GHCR"]
LOGIN --> BUILD["Build imagem\n(target: service-runtime)"]
BUILD --> PUSH_IMG["Push\nghcr.io/.../\nmanagement-service\n:development"]
end
CI --> CD
subgraph CD["CD — Deploy"]
DEPLOY["Runner dedicado\n(dev-deploy)"]
DEPLOY --> SCRIPT[".deploy/development/\ndeploy.sh"]
end
style PUSH fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
style CHECKOUT text-align:left
style BUILDX text-align:left
style LOGIN text-align:left
style BUILD text-align:left
style PUSH_IMG fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style DEPLOY text-align:left
style SCRIPT fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
Detalhes das etapas:
-
CI — Build & Push (roda em
ubuntu-latest):- Checkout do código.
- Configura QEMU + Docker Buildx para build multi-arquitetura.
- Login no GitHub Container Registry (GHCR) com
GITHUB_TOKEN. - Build da imagem Docker a partir de
.docker/Containerfile(targetservice-runtime). - Push para
ghcr.io/<owner>/management-service:development. - Build args:
BUILD_TIME,GIT_COMMIT_HASH(para rastreabilidade). - Cache: registry-based (max mode) para builds incrementais rápidos.
-
CD — Deploy (roda em runner dedicado
dev-deploy):- Depende do CI completar com sucesso.
- Environment:
development(comDEPLOY_URL). - Executa
.deploy/development/deploy.sh.
As seções a seguir consolidam as regras e princípios que guiam o desenvolvimento. Se você leu o README até aqui, já encontrou a maioria delas em contexto — aqui estão reunidas para referência rápida.
Estas são as práticas essenciais que todo contribuidor deve seguir:
- Sempre rode
code:fix→typecheckapós qualquer alteração. A tarefa não está concluída sem ambos passando. - Escreva testes para command/query handlers. Helpers e mocks ficam em
src/test/. - Nunca delete registros fisicamente — use soft delete (exclusão lógica). As entidades já têm
dateDeleted.
- Siga a estrutura hexagonal dos módulos existentes. Ao criar um novo módulo, replique a estrutura de um módulo já consolidado (ex.:
campus). - Schemas Zod ficam no domínio e são reutilizados na apresentação. Nunca duplicar validação.
- Validação em duas camadas — na apresentação (DTO com
static schema) e no domínio (zodValidate()). - Transações são automáticas — nunca chamar
.transaction()manualmente. O interceptor global cuida disso. - Não instale
class-validator— o projeto usa exclusivamente Zod v4.
- Português (pt-BR): nomes de entidades de domínio e todas as suas propriedades (
Campus,nomeFantasia,razaoSocial). - Inglês: todo o resto — infraestrutura, métodos, utilitários, variáveis (
findAll,CommandHandler,dateCreated).
- Não use
as any— defina tipos adequados. - Não importe de
modules/@shared— é legado em remoção. Use@/domain/,@/shared/,@/infrastructure.*. - Não adicione extensões
.jsou.tsnos imports. - Não proponha code generation ou meta-programação para reduzir boilerplate — consistência é preferida.
Esta é a seção mais formal e densa do README — ela documenta os princípios de design que guiam todas as decisões de código. Não é necessário memorizar tudo; use como referência quando tiver dúvidas sobre "qual abordagem escolher".
O projeto segue princípios rigorosos de engenharia de software para garantir qualidade, manutenibilidade e escalabilidade:
| Princípio | Aplicação no projeto |
|---|---|
| SOLID | Cada handler tem uma responsabilidade. Repositórios são compostos de interfaces granulares (IRepositoryCreate, IRepositoryFindById). Dependências são invertidas via Symbols. |
| DRY | Schemas Zod definidos uma vez no domínio, reutilizados na apresentação. Metadata de campos definida em CampusFields, consumida por REST e GraphQL. |
| KISS | Handlers são funções pequenas e diretas. Sem abstrações desnecessárias. |
| YAGNI | Não implemente o que ninguém pediu. Não adicione parâmetros "por precaução". |
| SoC | Controllers não contêm lógica de negócio. Handlers não fazem queries SQL. Repositórios não validam regras de domínio. |
Cada dado ou regra tem uma única origem autoritativa no projeto. Isso elimina inconsistências e facilita manutenção:
graph TD
subgraph "Fonte única (domínio)"
SCHEMA["CampusSchema\n(Zod)"]
FIELDS["CampusFields\n(FieldMetadata)"]
end
subgraph "Consumidores"
ENT["Entidade de domínio\nCampus.create() / Campus.update()"]
DTO_REST["DTO REST\nstatic schema = CampusCreateSchema"]
DTO_GQL["DTO GraphQL\n@Field(() => String, field.gqlMetadata)"]
SWAGGER["Swagger\n(gerado automaticamente)"]
end
SCHEMA --> ENT
SCHEMA --> DTO_REST
FIELDS --> DTO_GQL
FIELDS --> SWAGGER
style SCHEMA fill:#e8a838,stroke:#b07c1e,color:#fff
style FIELDS fill:#e8a838,stroke:#b07c1e,color:#fff
Exemplos de SSOT no projeto:
| Dado/Regra | Fonte única | Quem consome |
|---|---|---|
| Validação de campos | CampusSchema (Zod, no domínio) |
Entidade (zodValidate), DTO REST (static schema), DTO GraphQL |
| Metadata de campos (descrição, nullable) | CampusFields (FieldMetadata) |
Decorators GraphQL (gqlMetadata), Swagger (swaggerMetadata) |
| Tipagem da entidade | ICampus = z.infer<typeof CampusSchema> |
Todo o código que manipula Campus |
| Configuração de paginação | paginateConfig() na infraestrutura |
findAll de cada repositório |
O que isso significa na prática: se uma regra de validação do Campus mudar (ex.: CNPJ passa a ser opcional), você altera apenas o CampusFields.cnpj e o CampusCreateSchema. A validação na apresentação (DTO) e no domínio (zodValidate) atualiza automaticamente, porque ambos consomem o mesmo schema.
O projeto usa Inversão de Dependência para desacoplar as camadas. O domínio define interfaces (o que precisa), e a infraestrutura fornece implementações (como faz).
graph LR
subgraph "Domínio (interface/port)"
SYMBOL["Symbol\nICampusRepository"]
TYPE["Type\nICampusRepository"]
end
subgraph "Infraestrutura (implementação/adapter)"
IMPL["CampusTypeormRepository\n@DeclareImplementation()"]
end
subgraph "Aplicação (consumidor)"
HANDLER["CampusCreateCommandHandlerImpl\n@DeclareDependency(\n ICampusRepository\n)"]
end
SYMBOL -- "token de injeção" --> HANDLER
TYPE -- "contrato (tipos)" --> HANDLER
IMPL -- "registra como provider" --> SYMBOL
style SYMBOL fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style TYPE fill:#e8a838,stroke:#b07c1e,color:#fff,text-align:left
style IMPL fill:#50b86c,stroke:#3a8a50,color:#fff,text-align:left
style HANDLER fill:#4a90d9,stroke:#2c5f8a,color:#fff,text-align:left
Como funciona passo a passo:
1. O domínio define o contrato (o que o repositório deve fazer):
// src/modules/ambientes/campus/domain/repositories/campus.repository.interface.ts
export const ICampusRepository = Symbol("ICampusRepository"); // Token de injeção
export type ICampusRepository = // Contrato
IRepositoryFindAll<CampusListQueryResult> &
IRepositoryFindById<CampusFindOneQueryResult> &
IRepositoryFindByIdSimple<CampusFindOneQueryResult> &
IRepositoryCreate<ICampus> &
IRepositoryUpdate<ICampus> &
IRepositorySoftDelete;2. A infraestrutura implementa (como o repositório funciona):
// src/modules/ambientes/campus/infrastructure.database/campus.repository.ts
@DeclareImplementation()
export class CampusTypeormRepository implements ICampusRepository {
constructor(
@DeclareDependency(IAppTypeormConnection) private readonly conn: IAppTypeormConnection,
) {}
async create(entity: ICampus): Promise<{ id: string | number }> { /* ... usa TypeORM */ }
async findAll(...) { /* ... usa NestJS-Paginate */ }
}3. O handler consome (sem saber da implementação):
// src/modules/ambientes/campus/application/commands/campus-create.command.handler.ts
@DeclareImplementation()
export class CampusCreateCommandHandlerImpl {
constructor(
@DeclareDependency(ICampusRepository) private readonly repo: ICampusRepository,
) {}
async execute(ac: IAccessContext | null, dto: CampusCreateCommand) {
const campus = Campus.create(dto);
await this.repo.create(campus); // Não sabe se é TypeORM, Prisma ou mock
}
}Por que isso importa?
- O handler nunca sabe que está usando TypeORM. Ele conhece apenas o contrato.
- Em testes, você injeta um mock que implementa a mesma interface — sem banco de dados.
- Se o banco mudar de PostgreSQL para outro, apenas o adapter muda — zero alteração no domínio e na aplicação.
| Princípio | Aplicação no projeto |
|---|---|
| Clean Architecture | O domínio não depende de frameworks. Dependências apontam para dentro. |
| Hexagonal (Ports & Adapters) | Interfaces no domínio (ports), implementações na infraestrutura (adapters). |
| CQRS | Commands e queries separados em handlers distintos. |
| Bounded Context | Cada módulo é um contexto delimitado com seu modelo de domínio. |
| DDD | Entidades com identidade, factory methods, Ubiquitous Language (pt-BR para o domínio acadêmico). |
| Princípio | Aplicação no projeto |
|---|---|
| Fail Fast | Validação Zod na entrada (DTO) e no domínio. Erros descritivos imediatos. |
| Clean Code | Nomes semânticos, funções pequenas, early return, sem side effects ocultos. |
| POLA | APIs REST com convenções padrão. Nomes refletem o que fazem. |
| Law of Demeter | Handlers injetam repositórios, não connections. Controllers injetam handlers, não repositórios. |
| Immutability | Entidades mudam apenas via update(). Configurações são imutáveis. |
| Composition > Inheritance | DTOs usam mixins (ts-mixer), não herança profunda. |
| Categoria | Tecnologia | Versão |
|---|---|---|
| Runtime | Bun | latest |
| Linguagem | TypeScript | 5.9.3 |
| Framework | NestJS | 11.1.17 |
| ORM | TypeORM | 0.3.28 |
| Banco de dados | PostgreSQL | 15 (bitnamilegacy) |
| Documentação API | Swagger/OpenAPI + Scalar | NestJS Swagger 11.2 |
| GraphQL | Apollo Server | 5.4.0 |
| Validação | Zod | 4.3.6 |
| Autenticação | Keycloak + OAuth2/OIDC | Admin Client 26.5 |
| JWT/JWKS | jsonwebtoken + jwks-rsa | 9.0.3 / 4.0.1 |
| Passport | @nestjs/passport | 11.0.5 |
| Message broker | RabbitMQ via Rascal | 3-management / 21.0.1 |
| Processamento de imagens | Sharp | 0.34.5 |
| Paginação | nestjs-paginate | 12.9.0 |
| Eventos | @nestjs/event-emitter | 3.0.1 |
| Rate limiting | @nestjs/throttler | 6.5.0 |
| Agendamento | @nestjs/schedule | 6.1.1 |
| Segurança HTTP | Helmet | 8.1.0 |
| Compressão | compression | 1.8.1 |
| Mixins | ts-mixer | 6.0.4 |
| Containerização | Docker (recomendado) / Podman | — |
| Task runner | just | — |
| Monorepo | NX | 22.6.0 |
| Linting/Formatação | Biome | 2.4.8 |
| Testes | Vitest + Supertest | 4.1.0 / 7.2.2 |
| Coverage | @vitest/coverage-v8 | 4.1.0 |
- Docker não está rodando: verifique com
docker info. Se não estiver, inicie o Docker Desktop ou o daemon (sudo systemctl start docker). - Portas ocupadas: se outra aplicação usa as portas 3701, 5432 ou 15672, pare-a ou altere as portas no
.env/compose.yml. - Espaço em disco: containers e imagens Docker ocupam espaço. Limpe imagens não usadas com
docker system prune. - Rebuild necessário: se houve mudança no
Containerfileou dependências, force rebuild comjust rebuild.
- Banco não acessível: verifique se o container do PostgreSQL está rodando (
just logs). O banco precisa estar pronto antes de rodar migrações. - Migrações anteriores não aplicadas: se o banco foi resetado, rode
bun run migration:runpara aplicar todas desde o início. - Conflito de migração: se uma migração falha por tabela/coluna já existente, pode ser que o banco esteja em estado inconsistente. Use
bun run db:resetpara resetar completamente (perde dados).
- Diferença de UID: o container usa o usuário
happy(uid 1000). Se seu usuário no host tem uid diferente, pode haver problemas de permissão em volumes montados. Ojustfiletem a receitashell-rootpara acessar como root. - Podman: se usando Podman, certifique-se de que
userns_mode: keep-idestá configurado (já está nocompose.yml).
- Volume não montado: verifique se o código-fonte está montado como volume no container (deve aparecer em
docker compose ps). - Watchman/inotify: em Linux, pode ser necessário aumentar o limite de watches:
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p.
- Banco desatualizado: rode
bun run migration:runpara aplicar migrações novas. - Dependências desatualizadas: rode
bun installdentro do container. - Cache do Vitest: tente
bun run test --no-cache.
- Dependências instaladas? Rode
bun installdentro do container. - Tipos desatualizados? Se adicionou uma dependência nova, pode precisar dos
@types/*correspondentes. - IDE mostra erro mas
typecheckpassa (ou vice-versa): a IDE pode estar usando uma versão diferente do TypeScript. Otypecheckdo container é a fonte de verdade.
- Formato correto: o token deve ser
mock.matricula.<número>(ex.:mock.matricula.1234). Note: émock.matricula, nãomock.siape. - Usuário precisa existir: o mock token busca o usuário no banco pela matrícula. Se o usuário não existe, retorna 403. Rode
bun run migration:runpara inserir o seed (superuser). - Variável habilitada: verifique que
ENABLE_MOCK_ACCESS_TOKEN=trueno.env.
MIT © 2024 – presente, Ladesa.