Todos os artigos
139 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Tutoriais

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.

Controller, service e repository: responsabilidades
COVER · Tutoriais

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.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil