Em um dos projetos que passei recentemente, lidei com arquivos de mais de 2 mil linhas que ninguém sabia o que de fato faziam. O nível era tal que eu brincava com o time: parecia que só de tirar um System.debug (uma linha que não muda absolutamente nada no comportamento) alguma coisa quebrava em outro canto do org. Era esse o tamanho do medo de mexer naquele código.
A conclusão que cheguei é que não importa o projeto: o padrão é sempre o mesmo, não existe padrão nenhum. Uma hora alguém cria um controller que faz tudo de uma vez. Em outra, é uma Service escrevendo query direto. O resultado é sempre previsível: código que ninguém entende e que ninguém consegue dar manutenção.
Não é falta de talento. Quem desenvolveu entende de negócio, entende de Salesforce. O problema é que ninguém estabeleceu um vocabulário compartilhado. O que é uma Service? O que é um DAO? Onde vai a regra de negócio? Sem essa resposta combinada, cada desenvolvedor faz do jeito que acha melhor, o org cresce, e depois de dois anos ninguém muda nada sem medo de quebrar três outros processos.
No mundo real nem sempre temos tempo para um código perfeito, mas definir padrões de projeto já deixa o trabalho 90% encaminhado. Esse post é o guia que eu aplico quando entro numa org nova.
O problema começa na trigger
A trigger tem um trabalho só: detectar o evento e delegar. Só isso.
Na prática, é isso que eu encontro:
trigger AccountTrigger on Account (before insert, after insert, before update, after update) {
if (Trigger.isAfter && Trigger.isInsert) {
List<Task> tasks = new List<Task>();
for (Account acc : Trigger.new) {
// SOQL dentro de loop: estouro de governor limit garantido em volume
List<Contact> contacts = [
SELECT Id, Email FROM Contact WHERE AccountId = :acc.Id
];
if (acc.Industry == 'Technology') {
Task t = new Task();
t.Subject = 'Onboarding Tech';
t.WhatId = acc.Id;
// mais 20 linhas de lógica aqui
tasks.add(t);
}
}
insert tasks;
// chamada pra API externa aqui também
for (Account acc : Trigger.new) {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.externa.com/accounts');
// ...
}
}
if (Trigger.isBefore && Trigger.isUpdate) {
// mais 100 linhas...
}
}
Essa trigger faz quatro coisas diferentes. Quando a regra de onboarding mudar, você mexe aqui. Quando a API externa mudar, você mexe aqui. Quando quiser testar a lógica de Task isoladamente, não consegue: ela está amarrada ao contexto de trigger.
Trigger Handler: o primeiro passo
O padrão mais simples que já elimina 80% do caos. A trigger chama um handler, e o handler delega para quem sabe resolver.
// AccountTrigger.trigger
trigger AccountTrigger on Account (before insert, after insert, before update, after update) {
AccountTriggerHandler handler = new AccountTriggerHandler();
if (Trigger.isBefore && Trigger.isInsert) handler.beforeInsert(Trigger.new);
if (Trigger.isAfter && Trigger.isInsert) handler.afterInsert(Trigger.new);
if (Trigger.isBefore && Trigger.isUpdate) handler.beforeUpdate(Trigger.new, Trigger.oldMap);
if (Trigger.isAfter && Trigger.isUpdate) handler.afterUpdate(Trigger.new, Trigger.oldMap);
}
// AccountTriggerHandler.cls
public class AccountTriggerHandler {
public void afterInsert(List<Account> newAccounts) {
AccountService.processarNovasContas(newAccounts);
}
public void beforeUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
AccountService.validarAlteracoes(newAccounts, oldMap);
}
}
A trigger virou três linhas por evento. O handler é um direcionador: não tem lógica, só sabe quem chamar.
Service: onde a lógica de negócio vive
A Service é a classe que sabe as regras do negócio. Ela não sabe de triggers, não sabe de HTTP, não escreve SOQL. Ela recebe dados, aplica regras e chama quem precisa para persistir ou comunicar.
public class AccountService {
public static void processarNovasContas(List<Account> contas) {
List<Account> contasTech = new List<Account>();
for (Account conta : contas) {
if (conta.Industry == 'Technology') {
contasTech.add(conta);
}
}
if (!contasTech.isEmpty()) {
List<Task> tasks = criarTasksOnboarding(contasTech);
insert tasks;
IntegracaoExternaService.notificarNovasContas(contasTech);
}
}
private static List<Task> criarTasksOnboarding(List<Account> contas) {
List<Task> tasks = new List<Task>();
for (Account conta : contas) {
tasks.add(new Task(
Subject = 'Onboarding Tech — ' + conta.Name,
WhatId = conta.Id,
ActivityDate = Date.today().addDays(3)
));
}
return tasks;
}
}
Perceba o que a Service não faz: não tem SOQL, não tem HttpRequest, não tem lógica de trigger. Quando a regra de onboarding mudar (o prazo, o subject, a condição), você muda aqui e só aqui.
DAO: quem escreve SOQL
Em todo org bagunçado, o mesmo SOQL aparece em quatro lugares diferentes, às vezes com campos diferentes, às vezes sem um campo que alguém precisava. Quando um campo muda de nome, você caça em todos os lugares.
O DAO (Data Access Object) centraliza as queries. É a única classe que escreve SOQL para um determinado objeto.
public class AccountDAO {
public static List<Account> buscarPorIds(Set<Id> ids) {
return [
SELECT Id, Name, Industry, OwnerId, Active__c
FROM Account
WHERE Id IN :ids
];
}
public static List<Account> buscarTechComContatos(Set<Id> ids) {
return [
SELECT Id, Name, Industry,
(SELECT Id, Name, Email FROM Contacts WHERE HasOptedOutOfEmail = false)
FROM Account
WHERE Id IN :ids
AND Industry = 'Technology'
];
}
public static Integer contarContatos(Id accountId) {
return [SELECT COUNT() FROM Contact WHERE AccountId = :accountId];
}
}
Agora a Service não precisa saber SOQL, ela só chama o DAO:
public static void processarAlteracoes(List<Account> contas, Map<Id, Account> oldMap) {
Set<Id> idsAlterados = new Set<Id>();
for (Account conta : contas) {
if (conta.Industry != oldMap.get(conta.Id).Industry) {
idsAlterados.add(conta.Id);
}
}
List<Account> contasComContatos = AccountDAO.buscarTechComContatos(idsAlterados);
// aplica lógica...
}
Controller: a porta de entrada do LWC
A Controller é a ponte entre o front (LWC) e o back (Apex). E o trabalho dela é justamente esse, e nada além disso: expor métodos @AuraEnabled e repassar a chamada para a Service. Sem regra de negócio, sem SOQL, sem montar dado.
public class AccountController {
@AuraEnabled(cacheable=true)
public static AccountDetailWrapper buscarDetalhe(Id accountId) {
return AccountService.montarDetalhe(accountId);
}
@AuraEnabled
public static void ativarConta(Id accountId) {
AccountService.ativar(accountId);
}
}
Se você bater o olho numa Controller e ela tiver um for, um SOQL ou um if com regra de negócio, alguém colocou lógica no lugar errado. A Controller é fina de propósito: o dia que você trocar o LWC por uma integração REST, a regra continua intacta na Service.
Wrapper: seu escudo contra o mundo externo
Dois casos onde o Wrapper aparece no ecossistema Salesforce.
Caso 1: dados pra LWC. Quando um componente precisa de campos de objetos diferentes, ou de campos calculados que não existem no banco, o Wrapper agrupa tudo numa estrutura só.
public class AccountDetailWrapper {
@AuraEnabled public Account record { get; set; }
@AuraEnabled public Integer totalContatos { get; set; }
@AuraEnabled public String statusLabel { get; set; }
@AuraEnabled public Boolean podeFaturar { get; set; }
public AccountDetailWrapper(Account acc, Integer totalContatos) {
this.record = acc;
this.totalContatos = totalContatos;
this.statusLabel = acc.Active__c == true ? 'Ativo' : 'Inativo';
this.podeFaturar = acc.Active__c == true && totalContatos > 0;
}
}
Repare como ele se conecta com a Controller que vimos agora há pouco: aquele método buscarDetalhe tem como tipo de retorno justo esse Wrapper. A Controller expõe, a Service constrói o new AccountDetailWrapper(...) usando o DAO para buscar os dados:
// dentro de AccountService
public static AccountDetailWrapper montarDetalhe(Id accountId) {
Account acc = AccountDAO.buscarPorIds(new Set<Id>{ accountId })[0];
Integer total = AccountDAO.contarContatos(accountId);
return new AccountDetailWrapper(acc, total);
}
No componente LWC, você recebe um objeto limpo com tudo que precisa:
// accountDetail.js
import buscarDetalhe from '@salesforce/apex/AccountController.buscarDetalhe';
@wire(buscarDetalhe, { accountId: '$recordId' })
wiredAccount({ data, error }) {
if (data) {
this.statusLabel = data.statusLabel;
this.podeFaturar = data.podeFaturar;
}
}
Caso 2: resposta de API externa. Quando você consome um serviço externo, o Wrapper mapeia o JSON de resposta para uma classe Apex tipada. Você não passa String por todo o código.
public class IntegracaoExternaWrapper {
public String transacaoId { get; set; }
public String status { get; set; }
public Decimal valor { get; set; }
public String erro { get; set; }
public static IntegracaoExternaWrapper fromJson(String responseBody) {
return (IntegracaoExternaWrapper) JSON.deserialize(
responseBody, IntegracaoExternaWrapper.class
);
}
public Boolean sucesso() {
return status == 'APROVADO' && String.isBlank(erro);
}
}
Agora, quando a API mudar o formato de resposta, você muda o Wrapper e só o Wrapper.
Como tudo se encaixa
Trigger LWC
└── TriggerHandler └── Controller (expõe @AuraEnabled)
└── Service ◄───────────────────┘
├── DAO (queries SOQL)
├── Wrapper (estrutura de dados)
└── *Service (outros domínios ou integrações)
O LWC chama um Controller Apex, o Controller chama a Service, e a Service chama o DAO e o Wrapper. A trigger entra pelo mesmo ponto: TriggerHandler chama a Service. Repare que a Service é o centro de tudo: tanto a trigger quanto a Controller convergem para ela.
A regra que resume tudo: cada classe tem uma razão para mudar. A trigger muda quando o evento muda. O handler muda quando o mapeamento de evento muda. A Controller muda quando o contrato com o LWC muda. A Service muda quando a regra de negócio muda. O DAO muda quando os campos ou condições da query mudam. O Wrapper muda quando a estrutura de dados que o LWC precisa muda.
Se você consegue listar mais de uma razão para editar uma classe, ela está fazendo coisa demais.
Por onde começar se o org já existe
Não tente refatorar tudo de uma vez. Isso cria conflito, aumenta o risco de regressão e dificilmente passa por aprovação.
O que funciona: na próxima feature que for implementar, aplique os padrões nela. Cria a Service, o DAO, o Wrapper para aquela feature específica. Com o tempo o padrão se espalha, e quando alguém precisar mexer no código antigo, vai ver o padrão novo do lado e seguir.
Consistência vem de convenção, não de refatoração em massa.
Trabalho com arquitetura e desenvolvimento Salesforce para empresas que querem um org sustentável, sem o medo de quebrar tudo a cada mudança. Se isso descreve o seu cenário, dá uma olhada no que faço em arthurmenezes.dev.