Início › Desafios › Desafio 04 › Gabarito
Gabarito: uma task por venda, mesmo com o trigger rodando duas vezes
Se você ainda não tentou resolver, volte e tente: o valor deste desafio está em descobrir sozinho por que a sua primeira solução duplica. Este gabarito explica as três decisões que separam um trigger de demonstração de um trigger que sobrevive a uma org com automação de verdade.
Spoiler à frente. A solução completa está logo abaixo. Voltar para o desafio
O problema em uma frase
Quando um Flow ou workflow atualiza um campo do mesmo registro, o Salesforce executa o seu trigger de novo dentro da mesma transação, e nessa reexecução os valores antigos apresentados são os originais do início da transação. Para o seu código, parece que a oportunidade acabou de ser ganha outra vez: a comparação com o valor anterior dá verdadeiro de novo, e a tarefa é criada em dobro. Não aparece erro nenhum. Só aparece o dado duplicado, dias depois, na frente do time.
A solução completa
trigger OpportunityTrigger on Opportunity (after update) {
// Trigger fino: zero logica aqui. So delega com o contexto completo.
OnboardingHandler.processarGanhas(Trigger.new, Trigger.oldMap);
}
public with sharing class OnboardingHandler {
// Ids ja atendidos NESTA transacao. Static vive e morre com a transacao:
// segura a reexecucao de agora e nao vaza para a proxima operacao.
private static Set<Id> jaProcessadas = new Set<Id>();
public static void processarGanhas(List<Opportunity> novas, Map<Id, Opportunity> antigas) {
if (novas == null || novas.isEmpty()) {
return;
}
List<Task> paraCriar = new List<Task>();
for (Opportunity opp : novas) {
Opportunity antiga = (antigas == null) ? null : antigas.get(opp.Id);
Boolean entrouEmGanho = opp.StageName == 'Closed Won'
&& antiga != null
&& antiga.StageName != 'Closed Won';
if (entrouEmGanho && !jaProcessadas.contains(opp.Id)) {
jaProcessadas.add(opp.Id);
paraCriar.add(new Task(
WhatId = opp.Id,
OwnerId = opp.OwnerId,
Subject = 'Iniciar onboarding do cliente',
ActivityDate = Date.today().addDays(2)
));
}
}
if (!paraCriar.isEmpty()) {
insert paraCriar;
}
}
}
Destrinchando parte por parte
1. O trigger fino
OnboardingHandler.processarGanhas(Trigger.new, Trigger.oldMap);
Toda a lógica mora na classe, e o trigger só entrega o contexto. Não é estética: é o que permite testar a regra chamando o método direto (como o teste do desafio faz para simular a reexecução) e o que impede o trigger de virar um depósito de regras concorrentes conforme a org cresce. Um objeto, um trigger, um handler.
2. Detectar a mudança real, não o estado
Boolean entrouEmGanho = opp.StageName == 'Closed Won'
&& antiga != null
&& antiga.StageName != 'Closed Won';
A pergunta certa nunca é "está ganho?", e sim "acabou de virar ganho?". Sem a
comparação com o valor anterior, qualquer edição em oportunidade já fechada cria outra tarefa: mudou o
valor, corrigiu uma data, pronto, task nova. O Trigger.oldMap existe exatamente para essa
comparação. Só que ele tem o limite que este desafio expõe: na reexecução disparada por automação, os
valores antigos continuam sendo os originais da transação. A comparação volta a dar verdadeiro, e
sozinha ela não basta.
3. O guarda por registro (o coração do desafio)
private static Set<Id> jaProcessadas = new Set<Id>();
// ...
if (entrouEmGanho && !jaProcessadas.contains(opp.Id)) {
jaProcessadas.add(opp.Id);
Uma variável static em Apex dura exatamente uma transação. É o escopo perfeito para
registrar "estes Ids eu já atendi": a reexecução acontece na mesma transação, encontra o Id no
conjunto e pula; a próxima transação nasce com o conjunto vazio e funciona normalmente.
E por que um Set<Id> em vez do clássico Boolean jaRodou? Porque lote
grande executa o trigger em blocos de até 200 registros na mesma transação. Com 201
oportunidades, o primeiro bloco roda, o Boolean vira verdadeiro, e o segundo bloco (com o registro 201)
é ignorado por um guarda que não era sobre registros, era sobre a transação inteira. O registro 201
fica sem tarefa e ninguém percebe. O conjunto de Ids protege cada registro individualmente, que é a
granularidade certa do problema.
4. Coletar primeiro, um DML no fim
// dentro do laco: so decide e acumula
paraCriar.add(new Task(WhatId = opp.Id, Subject = 'Iniciar onboarding do cliente'));
// fora do laco: o banco e tocado UMA vez
if (!paraCriar.isEmpty()) {
insert paraCriar;
}
O laço só decide e acumula; o banco é tocado uma vez. Com 200 oportunidades ganhas, é a diferença entre
1 DML e 200 DML numa transação que permite 150. E o isEmpty() final cumpre a última regra
de aceite: rodada sem nenhuma ganha nova termina sem nenhum DML e sem exceção.
Erros comuns
- O Boolean estático. Resolve a duplicação e cria um bug pior: blocos além do primeiro são silenciosamente ignorados em lotes grandes. Troca um dado duplicado visível por um dado faltando invisível.
- Confiar só no oldMap. Funciona até a primeira automação que atualize o registro: a reexecução enxerga os valores originais e a "mudança" acontece de novo.
- Checar só o estado atual. Sem comparar com o anterior, toda edição em oportunidade ganha dispara o efeito de novo.
- DML dentro do laço. Passa no teste com três registros e estoura o limite de 150 DML no primeiro lote de verdade.
- Marcar com campo no registro (um checkbox "tarefa criada") em vez de memória de transação: o update do próprio campo dispara mais uma reexecução, que é justamente o que você queria evitar, além de sujar o modelo de dados.
- Limpar o Set no fim do método. O guarda precisa durar a transação inteira; limpar cedo demais reabre a porta para a reexecução duplicar.
Como saber se a sua solução passou
Rode o teste do desafio: 201 oportunidades viram Closed Won num único update (dois blocos
de trigger na mesma transação) e depois o handler é chamado de novo simulando a reexecução da
automação, com os valores antigos originais. Passou se o assert final conta
exatamente 201 tarefas: nem 200 (o Boolean engoliu o segundo bloco), nem 402 (a
reexecução duplicou tudo).
Um passo além
Em produção, três evoluções valem o estudo. Primeira: um interruptor de automação em
Custom Metadata, para poder desligar o trigger numa carga de dados sem deploy. Segunda: quando o efeito
colateral é pesado (callout, cálculo caro), tirá-lo da transação com um Queueable, e aí o
controle de reexecução muda de natureza. Terceira: decidir com critério o que vive em trigger e o que
vive em Flow, porque ter os dois no mesmo objeto sem coordenação é como esta duplicação nasce; o
artigo Flow ou Apex: quando usar cada
um trata exatamente dessa fronteira, e
os 6 governor limits que mais estouram
mostra o que acontece quando o DML em laço encontra um lote de verdade.
O aprendizado que fica: trigger de produção se escreve para a segunda execução, não para a primeira. A primeira qualquer código passa; é a reexecução, o lote de 201 e a automação criada seis meses depois por outra pessoa que separam o código que funciona do código que aguenta.