O Controller que virou um monstro (e como a gente parou de criar esses)
De 10 para 800 linhas: o ciclo de vida perigoso de um sistema Laravel mal estruturado. Descubra como aplicamos o Gateway Pattern para isolar integrações complexas e manter o código limpo e sustentável.
Fat Controller não é praticidade — é dívida técnica com juros compostos. E quando você adiciona integrações externas no meio disso, a conta chega mais rápido do que você imagina.
Existe um momento muito específico no ciclo de vida de todo sistema Laravel.
Você começa o projeto com boas intenções. O Controller está limpo. Tem dez linhas. Funciona.
Aí vem o cliente com uma nova regra. Depois outra. Depois a integração com o gateway de pagamento. Depois o webhook. Depois o log. Depois a notificação.
Três meses depois você abre o mesmo arquivo e ele tem 800 linhas. Você não consegue mais ler sem perder o fio. Qualquer mudança parece um jogo de campo minado.
Isso tem nome: Fat Controller. E a maioria dos projetos que chegam até nós para "dar uma melhorada" está cheio deles.
O problema não é o tamanho. É o que está dentro.
Um Controller grande não é necessariamente um problema de estética. O problema é o que ele passou a fazer que não deveria:
Validar dados
Aplicar regras de negócio
Chamar APIs externas diretamente
Formatar respostas
Enviar e-mails
Atualizar o banco
Tudo junto. Tudo misturado. Sem separação.
Quando isso acontece, você perde a capacidade de testar, reutilizar e, principalmente, de entender o que o código está fazendo sem ler cada linha do começo ao fim.
Mais grave ainda: quando chega uma mudança — e ela sempre chega — você não sabe exatamente onde mexer sem quebrar outra coisa.
Isso não é praticidade. É dívida técnica com juros compostos.
A saída que a gente usa: Services e Actions
A primeira mudança que fazemos em todo projeto que construímos ou recebemos para evoluir é tirar a lógica do Controller.
O Controller tem uma única responsabilidade: receber uma requisição e devolver uma resposta. Só isso.
A lógica de negócio vai para uma Service. Um caso de uso específico vai para uma Action. Os dados transitam como DTOs tipados — nunca como arrays soltos que você não sabe o que contém.
Na prática, um Controller nosso parece com isso:
class LeadController extends Controller
{
public function store(StoreLeadRequest $request, LeadService $service): Response
{
$lead = $service->create(LeadData::from($request->validated()));
return Inertia::render('Leads/Show', ['lead' => $lead]);
}
}
Quatro linhas. Sem surpresa. Sem medo de abrir o arquivo.
Mas aí chegou a integração externa. E tudo ficou feio de novo.
Esse é o segundo problema que a gente vê repetir em todo projeto que tem alguma integração com API externa — gateway de pagamento, CRM, ERP, serviço de e-mail, o que for.
A solução mais comum, especialmente quando o projeto já está corrido, é chamar a API diretamente de dentro da Service. Ou pior — do Controller.
// O que a gente vê frequentemente
$response = Http::withToken(config('asaas.key'))
->post('https://api.asaas.com/v3/customers', [
'name' => $data->name,
'cpfCnpj' => $data->document,
]);
Funciona? Funciona. Mas agora sua lógica de negócio está acoplada a uma API externa. Se o Asaas mudar um endpoint, você vai caçar esse código em vários arquivos. Se você quiser testar a Service em isolamento, vai precisar mockar chamadas HTTP. Se você quiser trocar de gateway um dia, o projeto vira uma cirurgia.
A solução: Gateway Pattern
O que a gente faz é simples, mas muda tudo: criar uma camada dedicada para cada integração externa.
Um namespace próprio. Uma responsabilidade única: falar com aquela API específica e devolver dados no formato que o restante da aplicação entende.
A estrutura fica assim:
app/
Gateways/
Asaas/
AsaasGateway.php ← o contrato (interface)
AsaasClient.php ← a implementação real
Schemas/
CreateCustomerSchema.php ← o que enviamos
CustomerResponse.php ← o que recebemos
Mappers/
CustomerMapper.php ← transforma resposta em DTO interno
A sua Service fala com o Gateway — não com a API diretamente. O Gateway fala com a API e devolve um DTO interno. A API externa nunca "vaza" para o resto do sistema.
Na prática:
final readonly class FinanceiroService
{
public function __construct(
private AsaasGateway $gateway
) {}
public function registrarCliente(ClienteData $data): Cliente
{
$schema = new CreateCustomerSchema(
name: $data->nome,
cpfCnpj: $data->documento,
);
$response = $this->gateway->createCustomer($schema);
return CustomerMapper::toInternal($response);
}
}
A Service não sabe nada sobre HTTP, sobre tokens, sobre a estrutura de resposta do Asaas. Ela fala com um contrato. O contrato pode ser trocado, mockado em testes, ou substituído por outro gateway amanhã — sem tocar uma linha da lógica de negócio.
O que isso muda na prática
Quando implementamos esse padrão no Omniaflow — um ERP educacional multi-tenant com integração profunda no Asaas para PIX, boletos e conciliação via webhooks — a diferença foi imediata.
Qualquer mudança no gateway fica contida. Os testes das regras financeiras rodam sem fazer uma única chamada HTTP real. E o desenvolvedor que abre o código pela primeira vez consegue entender o fluxo sem precisar caçar onde a API é chamada.
Isso não é over-engineering. É o custo de construir algo que vai durar.
Resumindo para levar
Fat Controller não é um estilo de programar — é uma dívida que você vai pagar mais tarde, com juros, quando o projeto estiver maior e mais difícil de mudar.
Gateway Pattern não é burocracia — é a diferença entre um sistema acoplado a um fornecedor externo e um sistema que controla suas próprias dependências.
As duas coisas têm o mesmo princípio por baixo: separar o que muda do que não muda. Regra de negócio muda. API externa muda. O fluxo principal do sistema deve ser protegido das duas.
É isso que a gente aplica em todo projeto que sai daqui.
Esse post faz parte da série de insights do blog da MC — pensamentos diretos sobre desenvolvimento, arquitetura e o que aprendemos construindo sistemas reais.
Tem um sistema que cresceu além do controle? Fala com a gente.
