Controller, service e repository: responsabilidades
O que controller, service e repository fazem de fato, os anti-patterns (fat controller, service anêmico, repository vazando ORM) e quando ter as três camadas é overkill.
Abri um arquivo OrderController.php outro dia e encontrei 600 linhas. Dentro: SQL inline montado com concatenação de string, cálculo de imposto, regra de desconto por tier de cliente, validação de payload, e no meio disso um header('Location: ...'). O método store() sozinho tinha 180 linhas. Ninguém conseguia testar nada sem subir o framework inteiro, e qualquer mudança no cálculo de imposto exigia rezar antes do deploy. Esse é o sintoma clássico de quem nunca separou responsabilidades entre controller, service e repository.
Este texto é sobre o que cada uma dessas três camadas faz, o que ela não deve fazer, e quando vale a pena ter as três.
O controller cuida da fronteira HTTP, e só disso
O controller é o ponto onde o mundo HTTP encosta no seu código. Responsabilidade dele: ler a request, validar o formato do input (existe o campo? é um número? bate o schema?), chamar o service certo, e formatar a response. Só isso.
O que o controller não faz: regra de negócio. Ele não decide se o cliente tem desconto, não calcula imposto, não sabe se um pedido pode ou não ser cancelado. Ele traduz HTTP para uma chamada de método e traduz o retorno de volta para HTTP. Se você trocasse a aplicação de REST para uma fila de mensagens amanhã, o controller seria descartado e a business logic continuaria intacta.
class OrderController {
function store(request) {
input = validate(request.body) // formato, não regra
order = orderService.place(input) // delega
return json(order, 201) // formata response
}
}
Três linhas úteis. Um controller magro é o objetivo, não um acidente. Validação de formato aqui é ok (DTO, request validation do framework). Validação de regra de negócio ("esse SKU está em estoque?") não é formato, é business logic, e vai para o service.
O service é onde mora a regra de negócio
O service orquestra. Ele é o lugar das business rules: calcula o imposto, aplica o desconto por tier, decide a transição de estado do pedido, abre e fecha a transaction. Idealmente ele é framework-agnostic: não conhece Request, não conhece Response, não conhece status code HTTP. Você consegue chamá-lo de um controller, de um job em background, de um comando de CLI ou de um teste, sem mudar uma linha.
Isso é o que torna a coisa testável. Um teste de service é um teste de função: entra um input de domínio, sai um resultado de domínio, com os repositories mockados. Sem subir HTTP, sem banco real.
class OrderService {
function place(input) {
customer = customerRepo.findById(input.customerId)
if (!customer.canOrder()) throw DomainError(...)
total = pricing.apply(input.items, customer.tier)
order = Order.create(customer, input.items, total)
return orderRepo.save(order) // persiste via repository
}
}
Repare que o service não escreve SQL. Ele pede "salva esse pedido" e não quer saber como.
O repository abstrai o acesso a dados
O repository é a fronteira com o banco. Ele esconde o ORM, o SQL, o detalhe de persistência, e devolve objetos de domínio, não linhas cruas. orderRepo.findById(id) retorna um Order, não um array associativo nem uma Entity do ORM vazando para fora. A ideia da camada é ter uma costura (seam): se amanhã você trocar Postgres por outra coisa, ou trocar o ORM, só o repository muda. O service nem fica sabendo.
Ao definir esse contrato entre as camadas, gerar os tipos a partir de um JSON de resposta poupa tempo e erros: cole o payload em um conversor de JSON para TypeScript e você já tem as interfaces do domínio para tipar o que o repository devolve.
Por que separar mesmo
Três motivos concretos, não filosofia:
- Testabilidade. Service sem framework = teste rápido e isolado. O controller fica tão burro que quase não precisa de teste unitário.
- Banco trocável. A costura do repository deixa você mockar persistência nos testes e, no limite, trocar a tecnologia de armazenamento sem tocar na regra.
- Controllers magros. Quando a business logic está no service, o controller vira um roteador de três linhas. Diff pequeno, review fácil, menos lugar pra bug.
Os anti-patterns que você vai encontrar
Fat controller. O caso das 600 linhas lá de cima. Tudo amontoado na fronteira HTTP. Impossível de testar sem subir o framework, impossível de reusar.
Service anêmico que é só passthrough. O outro extremo: OrderService.save() que só chama orderRepo.save() e retorna. Isso não é uma camada, é um custo de digitação. Se o service não tem regra, ele não tem motivo pra existir ali no meio.
Repository vazando o ORM. O mais sutil e o pior. O repository retorna um IQueryable, um query builder, um EntityManager, ou uma entity lazy-loaded que estoura query no service quando você acessa uma propriedade. Nesse momento o ORM vazou para cima e a costura quebrou: o service agora depende do detalhe que o repository deveria esconder. Repository retorna dado pronto, objeto de domínio, lista materializada. Não retorna promessa de query.
Quando isso é overkill
Honestamente: nem todo projeto precisa das três camadas. Um CRUD minúsculo, um admin interno de um formulário, um endpoint que só faz SELECT * WHERE id e devolve, não ganha nada com três arquivos e duas interfaces no meio. Você só adicionou cerimônia. A regra que eu sigo: a business logic sempre vive no service; o controller sempre fica burro; mas o repository só entra quando você realmente precisa da costura. Não adicione um repository só para embrulhar o ORM 1:1 — se ele não esconde nada, ele não abstrai nada, e você só renomeou o data access. Adicione quando precisar do seam: trocar de banco, mockar em teste, ter mais de uma fonte de dados.
Quem quer descer mais fundo na ideia de inverter as dependencies e isolar o domínio por trás de ports e adapters, o irmão deste texto é arquitetura hexagonal.
Perguntas frequentes
Onde fica a validação, no controller ou no service?
Depende do tipo. Validação de formato (campo existe, tipo certo, bate o schema) fica no controller, antes de chamar o service. Validação de regra de negócio ("o cliente pode comprar?", "tem estoque?") fica no service, porque depende de estado do domínio e não do shape da request.
Preciso de uma interface para cada repository?
Não por padrão. A interface só paga seu custo quando você realmente tem duas implementações ou quer mockar sem subir o banco. Em time pequeno e banco único, uma classe concreta de repository já entrega a separação. Adicione a interface quando a costura virar necessidade real.
O service pode chamar outro service?
Pode, é normal em orquestrações maiores (um CheckoutService chamando PaymentService e InventoryService). O cuidado é não criar dependency circular e manter cada service com uma responsabilidade clara. Se dois services se chamam em loop, a fronteira entre eles está no lugar errado.
Repository é a mesma coisa que DAO?
São parentes. Na prática um DAO costuma ser mais colado na tabela (um por tabela, CRUD cru), enquanto o repository pensa em termos de agregado de domínio e devolve objetos de domínio. A intenção do repository é esconder persistência atrás de uma linguagem de domínio; o DAO só encapsula o data access.
O que levar daqui
Controller cuida de HTTP e fica burro. Service segura a regra de negócio e não conhece framework. Repository esconde o banco e devolve domínio, não linhas. Separe por essas três responsabilidades, não por dogma: a business logic sempre no service, o controller sempre magro, e o repository só quando você precisa da costura — nunca só para embrulhar o ORM um para um.
- 01 O que é CDN: por que ela acelera sites no mundo inteiro Entenda o que é uma CDN, como edge servers e cache reduzem latência e por que ela acelera sites no mundo inteiro — além de DDoS, WAF e TLS.
- 02 Salt, pepper, bcrypt e Argon2id: como proteger senhas de verdade Em 2012, o LinkedIn expôs 117mi de senhas. SHA-1 sem salt — 90% quebradas em 4h. Entenda o que cada camada de proteção resolve e por que Argon2id é a escolha certa hoje.