InícioDesafiosDesafio 04 › Gabarito

Gabarito · Desafio 04 Nível: Intermediário

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.