Backend HTTP da plataforma MD Stack. Este serviço expõe uma API analítica sobre competições de programação, instituições, times, organizadores e eventos, usando Rust, Axum e PostgreSQL.
Este README não é uma documentação de endpoints. O objetivo aqui é ajudar futuros desenvolvedores a entenderem:
- o papel do projeto dentro da stack
- a arquitetura interna
- como as camadas conversam entre si
- como ler o projeto usando a lente de
MVC + Service Layer - por que algumas decisões foram tomadas
- como evoluir o código sem precisar “adivinhar o padrão”
O projeto é, conceitualmente, um backend de leitura e agregação de dados.
Ele não segue o estilo clássico de MVC com um Model monolítico no padrão Active Record. Em vez disso, a arquitetura está mais próxima de:
Routes + ControllersService LayerRepository LayerDTOsSQL explícito com sqlx
Em outras palavras: a lógica de aplicação fica nos services, o acesso ao banco fica nos repositories, e os contratos HTTP ficam nos dtos.
Para quem vem de Rails, Laravel, Spring MVC, ASP.NET MVC ou qualquer stack parecida, a forma mais útil de entender este backend é: ele continua sendo compatível com a lógica de MVC, mas com uma Service Layer explícita e com o “Model” dividido em mais de uma peça.
No MVC clássico:
Controllerrecebe a requisiçãoModelrepresenta dados e regras de negócioViewrenderiza a resposta
Em uma aplicação HTML tradicional, a View costuma ser template. Em uma API JSON, a View normalmente não é uma página HTML: ela passa a ser a representação serializada da resposta, isto é, os objetos que serão transformados em JSON.
A Service Layer é uma camada intermediária entre controller e modelo/persistência. Ela existe para concentrar:
- casos de uso
- validação de entrada da aplicação
- orquestração entre múltiplas fontes de dados
- transformação e agregação de resultados
Em arquiteturas com Service Layer, a ideia é evitar dois extremos ruins:
- controllers “gordos”, com regra demais
- models “gordos”, misturando persistência, regra de negócio e formatação de resposta
A equivalência mais útil é esta:
| Conceito | Neste projeto | Observação |
|---|---|---|
Controller |
controllers/ |
Recebe Path, Query, State e delega |
View |
dtos/*/responses + serialização JSON do Axum |
Em API, a “view” é o payload JSON |
Model |
repositories/, repositories/types/, shared/types e parte da lógica de domínio usada pelos services |
O “model” não está concentrado em uma única pasta |
Service Layer |
services/ |
Camada explícita de caso de uso |
| Infra de entrada HTTP | routes/ |
Faz o wiring entre path e controller |
Este é o ponto mais importante para evitar confusão.
O projeto não tem uma pasta models/, mas isso não significa que ele “não tem model” no sentido arquitetural. O que aconteceu foi uma divisão de responsabilidades que, em outras stacks, ficariam todas juntas dentro do model.
Aqui, o papel de Model foi fatiado em quatro partes:
repositories/: como os dados são buscadosrepositories/types/: em que formato cru a query devolve os dadosshared/types/: tipos de domínio compartilhados, como enumsservices/: parte da regra de aplicação que combina e reorganiza os dados
Então a leitura correta não é “este projeto não tem model”. A leitura correta é:
- o
Modelexiste como responsabilidade arquitetural - mas ele não existe como uma camada única e monolítica
Como este projeto não renderiza HTML, a View aparece de outra forma:
dtos/<dominio>/responsesdefinem a estrutura do payload externo- o Axum serializa esses DTOs em JSON
Por isso, ao pensar em MVC aqui, vale usar a expressão View Model ou Response DTO em vez de “template”.
Se você quiser guardar um único mapa mental, use este:
Route -> Controller -> Service -> Repository -> Banco
|
-> Response DTO -> JSON
ou, na linguagem de MVC + Service Layer:
HTTP wiring -> Controller -> Service Layer -> Model/Persistence -> View(JSON)
- Rust 2024
- Axum para HTTP
- Tokio para runtime assíncrono
- SQLx para acesso a PostgreSQL
- Serde para serialização e desserialização
- Chrono para datas
- Mockall para mocks em testes unitários
- Docker para build e execução do serviço
A forma mais simples de subir o projeto é a partir da raiz de md-stack:
docker compose up --buildNesse caso:
- o PostgreSQL sobe via Compose
- o backend recebe
DATABASE_URLvia.env - o frontend/proxy passa a consumir este serviço
Pré-requisitos:
- PostgreSQL disponível
- variável
DATABASE_URLconfigurada
Exemplo:
export DATABASE_URL=postgres://user:password@localhost:5432/maratona_db
cargo runO fluxo de inicialização está em src/main.rs:
- lê
DATABASE_URL - cria um
PgPool - executa as migrations embutidas com
sqlx::migrate!() - monta
AppState - cria o router Axum
- sobe o servidor em
0.0.0.0:8000
Isso significa que o schema do banco é aplicado automaticamente no boot.
cargo check
cargo test
cargo runSe quiser validar o backend sem escrever artefatos em target/ do projeto, é possível apontar o target para outro diretório:
CARGO_TARGET_DIR=/tmp/backend-target cargo testsrc/
controllers/
dtos/
repositories/
routes/
services/
shared/
errors.rs
lib.rs
main.rs
state.rs
migrations/
Dockerfile
README.md
routes/: registra os endpoints HTTP por domíniocontrollers/: extraiPath,QueryeState, chama o service e devolve resposta HTTPservices/: valida entrada, orquestra chamadas aos repositórios e transforma dadosrepositories/: contratos e implementação de acesso a dadosrepositories/types/: tipos de linha retornados pelas queries SQLdtos/: contratos de entrada e saída da APIshared/: enums, tipos compartilhados e utilitários de serializaçãomigrations/: schema, tipos SQL, funções auxiliares e seed de dados
O fluxo principal do projeto é:
HTTP Request
-> route
-> controller
-> service
-> repository trait
-> SQL query
-> repository row types
-> service aggregation / mapping
-> response DTO
-> JSON Response
Se você preferir ler o mesmo fluxo em termos de MVC + Service Layer:
HTTP Request
-> HTTP wiring
-> Controller
-> Service Layer
-> Model/Persistence
-> View(JSON)
Uma requisição para buscar estruturas de competições segue esta lógica:
- a rota registra o endpoint em
routes/competitions.rs - o controller extrai
competition_idsda query string - o service valida que os IDs existem
- o service chama
repo.find_structures_by_ids(...) - o repository executa uma query SQL grande e denormalizada
- a query retorna várias linhas “achatadas”
- o service reagrupa essas linhas em competições, eventos e times
- o controller devolve
Json(Vec<CompetitionStructure>)
Na lente MVC + Service Layer: esta camada fica um passo antes do Controller. Ela é infraestrutura HTTP, não regra de negócio.
As rotas apenas organizam os endpoints por domínio.
Exemplo:
routes/competitions.rsroutes/teams.rsroutes/institutions.rs
Responsabilidade desta camada:
- declarar paths
- ligar path -> controller
- não conter regra de negócio
A agregação final acontece em routes::create_router(), que faz o merge das routes criadas em cada submódulo num objeto axum::Router. Na main, os endpoints declarados aqui são associados ao TcpListener pelo axum::serve().
Na lente MVC + Service Layer: esta é a camada Controller propriamente dita.
Os controllers são deliberadamente finos.
Exemplo simplificado de um controller:
pub async fn get_structures(
State(state): State<AppState>,
Query(filter): Query<StructuresQuery>,
) -> impl IntoResponse {
services::competitions::get_structures(&state.repo, filter.competition_ids.into_inner())
.await
.map(|structures| Json(structures))
}Responsabilidade desta camada:
- extrair parâmetros HTTP
- chamar o service correto
- converter o resultado em
Json(...) - deixar o Axum transformar
AppErrorem resposta HTTP
O controller não deveria:
- montar SQL
- fazer agrupamento de dados
- conter regra analítica significativa
Na lente MVC + Service Layer: esta é a própria Service Layer, explícita e central.
Os services são o coração da aplicação.
Eles fazem duas coisas principais:
- validação de entrada
- transformação/orquestração dos dados retornados pelos repositórios
Exemplos de responsabilidades típicas:
- exigir
year,start_year,end_yearou IDs obrigatórios - chamar um método de repositório
- transformar rows de banco em DTOs de resposta
- reagrupar dados achatados em estruturas hierárquicas
Em um MVC sem Service Layer, parte dessa lógica poderia acabar em controllers ou models. Aqui ela fica intencionalmente isolada em services/.
Na lente MVC + Service Layer: esta é a parte de persistência do Model.
A camada de repositório é dividida em duas partes:
- um trait por domínio, que define o contrato
- uma implementação concreta baseada em
Registry, que possui oPgPool
Exemplo conceitual:
#[async_trait]
pub trait CompetitionRepository: Send + Sync {
async fn find_structures_by_ids(&self, ids: Vec<i32>) -> AppResult<Vec<CompetitionStructureRow>>;
}E depois:
#[async_trait]
impl CompetitionRepository for Registry {
async fn find_structures_by_ids(&self, ids: Vec<i32>) -> AppResult<Vec<CompetitionStructureRow>> {
structures::find_structures_by_ids(self, ids).await
}
}Ou seja:
- o
traitdefine “o que o service precisa” - o
Registrydefine “como isso é executado com SQL real”
Na lente MVC + Service Layer: estes tipos ainda fazem parte do lado de Model, mas especificamente como query models ou read models internos.
A pasta repositories/types/ contém structs como CompetitionStructureRow, TeamStructureRow, EventPerformanceRow, etc.
Esses tipos não são entidades de domínio. Eles representam a forma exata da projeção SQL retornada por query_as.
Isso é importante porque este backend faz muitas queries analíticas e agregadas. Em vez de mapear tabela por tabela como um ORM faria, as queries retornam recortes específicos do banco já prontos para uso na aplicação.
Na lente MVC + Service Layer:
requestsficam do lado da borda HTTPresponsesfazem o papel mais próximo deViewem uma API JSON
Os dtos definem o contrato HTTP.
A divisão atual é:
dtos/<dominio>/requests: entrada HTTPdtos/<dominio>/responses: saída HTTPdtos/common: tipos compartilhados entre domínios
Exemplos:
YearQueryStructuresQueryCompetitionStructureEventPerformanceOptionItem
Regra prática:
- se o tipo existe para ler a requisição ou serializar a resposta, ele deve estar em
dtos - se o tipo existe para receber resultado cru do SQL, ele deve estar em
repositories/types
Na lente MVC + Service Layer: esta camada contém tipos transversais de domínio e infraestrutura leve, compartilhados entre várias partes do “Model” e da borda HTTP.
A pasta shared/ concentra tipos e utilitários reutilizados em todo o projeto.
Dois pontos importantes:
shared/types.rs: enums comoGenderCategory,LocationType,Scopeshared/serde.rs: desserializadores customizados, comoCsvOptVec<T>
CsvOptVec<T> permite aceitar filtros como:
?competition_ids=1,2,3
em vez de exigir arrays JSON ou múltiplos parâmetros repetidos.
Na lente MVC + Service Layer: esta é uma peça transversal. Ela atravessa controller, service e repository, mas o mapeamento final para HTTP acontece na borda.
errors.rs define:
AppErrorAppResult<T>- a conversão de erro para resposta HTTP com
IntoResponse
Hoje a aplicação trabalha basicamente com:
BadRequestDatabase
Isso mantém a assinatura dos services simples:
pub type AppResult<T> = Result<T, AppError>;Esta seção existe para quem vem de linguagens como Ruby, JavaScript, Python ou Java.
Um trait em Rust define um contrato: um conjunto de métodos que um tipo pode implementar.
Neste projeto, os services não dependem do tipo concreto Registry. Eles dependem de traits como:
CompetitionRepositoryTeamRepositoryInstitutionRepository
Isso permite trocar a implementação sem alterar o service.
Quando um service recebe algo assim:
repo: &dyn CompetitionRepositoryisso significa: “receba uma referência para qualquer valor que implemente CompetitionRepository”.
Para quem vem de OO, pense como algo parecido com:
- uma interface em Java/C#
- um objeto que responde a um protocolo específico
Porque isso desacopla a regra de negócio da infraestrutura.
O service não precisa saber:
- se a implementação usa PostgreSQL
- se usa SQLx
- se é um mock de teste
Ele só precisa saber que existe um método como find_structures_by_ids(...).
Nos testes unitários, o projeto usa mockall para gerar mocks automaticamente a partir dos traits.
Então, em vez de testar com banco real, dá para fazer:
- mockar o repositório
- devolver rows sintéticas
- validar apenas a transformação do service
Os traits de repositório usam Send + Sync porque o Axum/Tokio roda em ambiente assíncrono e potencialmente multi-thread.
Regra prática:
Send: o valor pode ser movido entre threadsSync: referências para o valor podem ser compartilhadas entre threads
Na maioria das vezes, pense nisso como uma exigência de segurança de concorrência.
Rust ainda trata traits assíncronos com algumas limitações de ergonomia. Para permitir async fn dentro de traits, o projeto usa a macro async_trait.
Sem ela, os signatures de trait ficariam bem mais verbosas.
Vários DTOs implementam From<RowType> ou From<TempType>.
Exemplo mental:
.map(CompetitionStructure::from)Isso é uma forma explícita e idiomática de dizer:
- “pegue este tipo interno”
- “converta para o tipo que será exposto pela API”
Muitos services seguem um pipeline parecido com este:
repo.find_structures_by_ids(ids)
.await?
.into_iter()
.fold(IndexMap::new(), |mut acc, row| {
// agrupa e reorganiza os dados
acc
})
.into_values()
.map(CompetitionStructure::from)
.collect()Para quem não está acostumado:
into_iter(): começa a iterar pelos itensfold(...): reduz vários itens em uma estrutura acumuladoramap(...): transforma item por itemcollect(): materializa o resultado final emVec<_>
Isso é muito usado aqui porque as queries retornam linhas achatadas, mas a API precisa devolver árvores como:
- competição -> eventos -> times
- instituição -> competições -> eventos -> times
- organizador -> competições -> eventos
IndexMap preserva a ordem de inserção.
Isso ajuda em dois pontos:
- JSON sai com ordem estável e previsível
- testes ficam menos frágeis, porque a ordem do resultado não muda aleatoriamente
O schema está em migrations/0001_create_schema.sql.
Algumas entidades centrais:
organizer: entidade que organiza competiçõescompetition: competição lógicaevent: tipo de evento dentro de uma competiçãoevent_instance: ocorrência concreta de um evento em uma data/localinstitution: universidade/escolateam: time ligado a uma instituiçãoteam_event: participação de um time em umevent_instancemember: pessoateam_event_member: membros ligados a uma participação do timesubmission: submissões em problemaslocation: árvore hierárquica de localizações
Esse é um ponto importante para quem vai manter o projeto.
eventrepresenta o tipo lógico do eventoevent_instancerepresenta uma edição concreta em uma data/local
Então “Regional” pode existir como evento lógico, mas ter várias instâncias ao longo dos anos.
O banco define uma função get_location_tree(start_location_id).
Ela é usada em várias queries para:
- montar strings de localização como
Country, State, City - calcular recortes agregados por
LocationType - descobrir quais níveis geográficos fazem sentido para um conjunto de times/eventos
Em termos de MVC + Service Layer, a resposta curta é: há “model” como responsabilidade arquitetural, mas não como uma pasta única chamada models/.
Este projeto não usa “model” no sentido tradicional de Rails/Active Record, isto é, uma entidade que ao mesmo tempo:
- conhece o banco
- executa queries
- contém regra de negócio
- sabe virar resposta externa
Em vez disso, essas responsabilidades foram repartidas entre:
repositories: acesso a dadosrepositories/types: formatos crus das queriesshared: tipos de domínio compartilhadosservices: regra de aplicação e transformaçãodtos: contrato externo da API
Isso foi uma decisão consciente.
Como este backend é muito orientado a consulta analítica e agregação, um modelo ORM clássico teria menos benefício do que SQL explícito com projeções específicas.
Os testes hoje estão concentrados principalmente na camada de service.
Isso conversa diretamente com a arquitetura MVC + Service Layer: a camada mais valiosa para testar isoladamente é justamente a que contém os casos de uso e a transformação dos dados.
Em vez de testar transformação com banco real, os tests:
- mockam o trait de repositório
- devolvem rows sintéticas
- validam o comportamento do service
Exemplo de benefícios:
- testes rápidos
- sem dependência de banco
- foco total na regra de aplicação
- falhas mais fáceis de localizar
Os traits usam:
#[cfg_attr(test, mockall::automock)]Isso faz com que mockall gere tipos como:
MockCompetitionRepositoryMockTeamRepositoryMockInstitutionRepository
Assim, o service pode ser testado isoladamente.
Se um controller começar a validar regra, agregar dados ou decidir estrutura de domínio, provavelmente a lógica está na camada errada.
Se uma regra envolve:
- validação de input
- combinação de dados
- transformação entre formatos
- agrupamento de rows em hierarquias
isso deve morar em services/.
Se algo precisa de banco, o lugar certo é repositories/.
Não coloque SQL em controller, DTO ou service.
Não exponha diretamente repositories/types/* para a API.
Esses tipos existem para refletir a query SQL, não o contrato HTTP.
Se você estiver em dúvida sobre o papel de dtos/*/responses, pense neles como a camada de apresentação da API: eles são o formato final que o cliente enxerga.
Enums e utilitários transversais devem ir para shared/. Se o tipo existe apenas para um endpoint ou domínio, provavelmente não deve ficar lá.
Fluxo recomendado para adicionar um novo endpoint/caso de uso:
- definir o contrato HTTP em
dtos/<dominio>/requestse/ouresponses - adicionar a rota em
routes/<dominio>.rs - criar o controller em
controllers/<dominio>/... - implementar o caso de uso em
services/<dominio>/... - declarar o método necessário no trait de repositório do domínio
- criar o row type em
repositories/types/...se necessário - implementar a query SQL no módulo correto de repository
- escrever testes unitários do service com
mockall
Se você não sabe onde colocar algo, faça a seguinte pergunta:
- isso é HTTP? ->
controlleroudto - isso é regra de aplicação? ->
service - isso é SQL/banco? ->
repository - isso é formato cru de query? ->
repositories/types - isso é apresentação da resposta? ->
dtos/*/responses
- SQL explícito e fácil de otimizar
- services testáveis sem banco
- controllers simples
- separação clara entre contrato HTTP e projeção SQL
- boa adequação para endpoints analíticos
- leitura arquitetural limpa para quem pensa em
MVC + Service Layer
- mais arquivos e mais “cerimônia” do que frameworks com ORM pesado
- queries podem ficar grandes e exigir disciplina de organização
- exige familiaridade com traits, async e transformação funcional
- o “Model” fica espalhado em mais de uma camada, o que exige onboarding melhor
Algumas melhorias naturais para o futuro:
- adicionar testes de integração para controller + banco
- revisar padronização de mensagens de erro
- adicionar observabilidade estruturada (logs, tracing, métricas)
- revisar endpoints e documentação pública da API separadamente deste README
- considerar separar melhor alguns
Temp*internos de agregação se a camada de DTO crescer muito - se o domínio ficar mais rico, avaliar a introdução de entidades de domínio explícitas sem abandonar a Service Layer
Se você lembrar de apenas uma coisa, lembre desta:
routesfazem o wiring HTTPcontrollersfazem o papel deControllerservicessão aService Layerrepositorieserepositories/typesrepresentam a parte persistente doModeldtos/*/responsessão aViewda API
Esse é o eixo central da manutenção do backend.