Desenvolvimento

Bulkificação no Apex: por que seu código quebra com 200 registros

Ícones lineares de registros, processamento em lote, banco de dados e validação

Existe uma classe de bug no Salesforce que nunca aparece no seu sandbox e nunca some sozinha em produção. O código roda verde no teste, passa no code review, sobe tranquilo. Semanas depois, um import de reposição entra com 200 linhas e a transação inteira morre com Too many SOQL queries: 101. Ninguém tocou no código. O que mudou foi o volume, e o volume é exatamente o que o seu teste de três registros nunca mediu. Bulkificar é a disciplina que separa o Apex que aguenta a carga real do que só aguenta a demonstração.

Seu Apex quebra com 200 registros porque faz SOQL ou DML dentro do loop, e o trigger recebe os registros em blocos de até 200 de uma vez. Bulkificar é processar a coleção inteira com um número fixo de queries: colete os IDs, faça uma consulta só e trabalhe em memória com um Map, fora do laço.

O que é bulkificação, e por que 200 é o número mágico

Bulkificação é a disciplina de escrever código que trata uma coleção de registros com um número constante de queries e DMLs, não importa se o lote tem 1 ou 200 registros. É o oposto do reflexo natural de quem aprendeu a programar fora do Salesforce, onde você itera sobre uma lista e, para cada item, vai ao banco buscar o que precisa. Nesse mundo, código ineficiente fica lento. No Salesforce, código não bulkificado simplesmente para, com uma exceção que você não consegue capturar.

O número 200 não é arbitrário. Ele é o tamanho do bloco com que a plataforma entrega registros a um trigger. Quando você importa 1.000 linhas, o Salesforce não dispara o trigger uma vez com 1.000 registros na mão. Ele quebra a carga em cinco execuções de 200, e o seu código roda cinco vezes, cada vez com um Trigger.new de no máximo 200. Cada uma dessas execuções é uma transação com o seu próprio orçamento de governor limits: 100 queries SOQL, 150 statements DML, e por aí vai.

A conta que derruba o código é simples. Se você tem uma query dentro do loop e o loop roda 200 vezes, são 200 queries numa transação que só permite 100. A 101ª iteração lança Too many SOQL queries: 101, a transação sofre rollback e a carga inteira daquele bloco não grava nada. No seu sandbox, você testou com três registros. O loop rodou três vezes, gastou três queries, tudo verde. O bug sempre esteve lá. O volume só o tornou visível.

O padrão que resolve quase tudo: coletar, consultar, mapear

A receita da bulkificação tem três passos que você repete em quase toda situação: colete os IDs que precisa, faça uma consulta só para todos eles, e trabalhe em memória com um Map. Veja um caso comum, uma trigger em Contact que copia o segmento de mercado da conta pai para um campo do contato.

Este é o jeito errado, o que estoura no primeiro import grande:

// NAO faca: uma query por iteracao
for (Contact c : Trigger.new) {
    Account conta = [SELECT Industry FROM Account WHERE Id = :c.AccountId];
    c.Segmento__c = conta.Industry;
    // 200 contatos = 200 queries = erro na 101a
}

E este é o mesmo trabalho bulkificado. Colete os IDs numa passada, faça uma query só, indexe o resultado num Map e deixe o loop apenas ler de memória:

// 1. colete os IDs dos pais
Set<Id> contaIds = new Set<Id>();
for (Contact c : Trigger.new) {
    if (c.AccountId != null) contaIds.add(c.AccountId);
}

// 2. uma unica query para todos
Map<Id, Account> contas = new Map<Id, Account>(
    [SELECT Id, Industry FROM Account WHERE Id IN :contaIds]);

// 3. trabalhe em memoria, zero query no loop
for (Contact c : Trigger.new) {
    Account conta = contas.get(c.AccountId);
    if (conta != null) c.Segmento__c = conta.Industry;
}

O construtor new Map<Id, Account>(...) aceita direto o resultado da query e indexa cada registro pelo próprio Id, o que torna o get dentro do loop instantâneo e sem custo de banco. O ganho não é estilo: o lote de 200 contatos passa a gastar uma query em vez de 200, e o mesmo código atende com folga um import de 10.000. Esse é o feijão com arroz da boa prática em Apex: nenhuma query e nenhum DML nunca deve morar dentro de um loop.

Quando o pai depende dos filhos: o padrão Map de lista

O exemplo acima é o caso fácil, onde cada filho tem um pai e você só busca um dado dele. O caso que trava a maioria dos devs é o inverso: quando você precisa atualizar o pai a partir de um cálculo sobre os filhos. Foi exatamente esse cenário que quebrou na Vetra Distribuidora, a empresa fictícia que mantenho como org de demonstração do portfólio: um trigger recalculava o desconto médio da oportunidade a partir das linhas de produto, porque a regra tinha faixa por família e não cabia num Roll-Up Summary padrão. Com uma oportunidade de poucas linhas, tudo verde. Ao simular um import de 40 oportunidades com dezenas de linhas cada, o trigger consultava as linhas de cada oportunidade dentro do loop e estourou o limite de queries. A causa era query no loop de novo, só que disfarçada de rollup.

A técnica que resolve é agrupar os filhos por Id do pai num único Map, montado a partir de uma consulta só, antes de qualquer cálculo:

// os IDs das oportunidades afetadas
Set<Id> oppIds = Trigger.newMap.keySet();

// agrupe as linhas por oportunidade, numa query so
Map<Id, List<OpportunityLineItem>> linhasPorOpp =
    new Map<Id, List<OpportunityLineItem>>();
for (OpportunityLineItem oli : [
        SELECT Id, OpportunityId, Discount
        FROM OpportunityLineItem
        WHERE OpportunityId IN :oppIds]) {
    if (!linhasPorOpp.containsKey(oli.OpportunityId)) {
        linhasPorOpp.put(oli.OpportunityId, new List<OpportunityLineItem>());
    }
    linhasPorOpp.get(oli.OpportunityId).add(oli);
}

Com o Map montado, o cálculo e a gravação também ficam em lote. Você itera sobre os pais, lê a lista de filhos do Map, calcula em memória e acumula numa lista para um único update no fim:

List<Opportunity> paraAtualizar = new List<Opportunity>();
for (Id oppId : oppIds) {
    List<OpportunityLineItem> linhas = linhasPorOpp.get(oppId);
    if (linhas == null || linhas.isEmpty()) continue;

    Decimal soma = 0;
    for (OpportunityLineItem oli : linhas) {
        soma += (oli.Discount == null ? 0 : oli.Discount);
    }
    paraAtualizar.add(new Opportunity(
        Id = oppId,
        Desconto_Medio__c = soma / linhas.size()));
}
update paraAtualizar;   // um DML para todas as oportunidades

Repare que existem dois loops aninhados na segunda parte, o de oportunidades e o de linhas, e isso está correto: os dois iteram sobre coleções já carregadas em memória. O que a bulkificação proíbe não é o loop aninhado, é o acesso ao banco dentro dele. Esse padrão de Map<Id, List<Filho>> é a ferramenta que resolve quase todo cenário de pai que depende de filho, e é justamente o que quase ninguém aprende antes de quebrar em produção.

DML no loop: o mesmo erro, do outro lado

Query dentro do loop estoura o limite de 100. O irmão gêmeo é o DML dentro do loop, que estoura o de 150. O padrão perigoso grava registro por registro:

for (Account a : contas) {
    a.Status__c = 'Ativo';
    update a;   // 200 contas = 200 DMLs = erro no 151o
}

A correção é a mesma lógica da query: acumule numa lista e faça um único DML sobre ela, fora do loop. Um update sobre uma lista de 200 registros conta como um statement DML, não 200. Como o mecanismo por trás desses tetos é o mesmo, tratei os governor limits e a razão de eles existirem em detalhe no guia dos limites que mais estouram; aqui o foco é a técnica que os respeita.

A conta que prova o ganho

O valor da bulkificação fica evidente quando você coloca os números lado a lado. A tabela abaixo mostra quantas queries o mesmo trabalho consome nas duas abordagens, conforme o tamanho do lote que o trigger recebe:

Registros no lote Query no loop Bulkificado Resultado
3 (seu teste) 3 queries 1 query Ambos passam
100 100 queries 1 query No limite, arriscado
200 (import real) 200 queries 1 query O ingênuo estoura na 101a
10.000 10.000 queries 1 query Só o bulkificado sobrevive

A leitura é direta. O código não bulkificado tem consumo linear: dobrou o volume, dobrou as queries, até bater no teto. O bulkificado tem consumo constante: uma query serve para qualquer tamanho de lote. É por isso que testar com três registros é uma armadilha. Nessa linha, os dois códigos passam, e o bug fica invisível até o dia em que o volume real chega.

Bulkifique, mas cuide da recursão

Um detalhe que morde quem acabou de bulkificar: a mesma transação pode disparar o seu trigger mais de uma vez. Se o seu código atualiza registros que reentram no próprio trigger, ou se um workflow revisita o registro, o loop bulkificado roda de novo e pode refazer trabalho ou empilhar queries entre as reexecuções. A defesa não é espalhar checagens pelo código, é centralizar o controle de contexto num trigger handler organizado, que decide o que roda em cada evento e evita a reentrada indevida. Bulkificação e um handler enxuto resolvem juntos a maioria dos problemas de trigger em produção.

E quando nem a bulkificação basta, porque o volume a processar é grande demais para caber numa transação de 200, o caminho deixa de ser o trigger síncrono e passa a ser o processamento assíncrono. A escolha entre Queueable, Batch e Schedulable é o próximo degrau: o Batch quebra milhões de registros em blocos com os limites renovados, cada bloco já bulkificado por dentro.

Quando você não precisa se preocupar com isso

Honestidade primeiro: se você é admin e nunca escreveu uma linha de Apex, provavelmente nunca vai bater de frente com bulkificação. O Flow, os fluxos padrão e a maioria das automações declarativas já operam de forma bulkificada por baixo dos panos, e o motor cuida disso por você. O risco real aparece quando existe código customizado, quando você chama Apex de dentro do Flow, ou quando várias automações disparam umas às outras na mesma transação.

E não caia no extremo oposto de otimizar o que não precisa. Se a sua trigger, por regra de negócio, nunca vai ver mais que um punhado de registros por vez, montar máquina de bulkificação elaborada é esforço desperdiçado. Mas a regra base é barata e vale sempre: nunca ponha SOQL ou DML dentro de um loop, e escreva o teste que insere 200 registros de uma vez. Fazendo só isso, você elimina a esmagadora maioria dos incidentes de volume, sem precisar de mais nada, nem de mim.

Perguntas frequentes

Por que o Apex quebra justamente com 200 registros?

Porque o trigger recebe os registros em blocos de até 200 por vez, e se há SOQL ou DML dentro do loop, cada registro consome uma query ou um DML do orçamento da transação. Com 101 iterações a query estoura no limite de 100, e o DML no de 150. O sandbox testa com poucos registros e nunca chega perto do teto, então o bug só aparece na carga real.

O que significa bulkificar um código Apex?

Bulkificar é escrever o código para processar uma coleção inteira com um número fixo de queries e DMLs, independente de o lote ter 1 ou 200 registros. A receita é coletar os IDs primeiro, fazer uma única consulta para todos, guardar o resultado num Map indexado por Id e depois iterar lendo de memória, sem tocar o banco dentro do loop.

Como bulkificar quando preciso atualizar registros pai a partir dos filhos?

Você agrupa os filhos por Id do pai num Map de Id para lista, numa passada só sobre uma query única. Depois itera sobre os pais lendo a lista de filhos correspondente do Map, calcula o novo valor em memória e faz um único update sobre a lista de pais. Nenhuma query ou DML mora dentro dos loops.

Bulkificação resolve o limite de registros ou ainda preciso de Batch Apex?

São problemas diferentes. Bulkificação resolve queries e DMLs dentro de uma transação, que trata até 200 registros por vez no trigger. Quando você precisa percorrer milhões de registros de uma vez, aí sim é Batch Apex, que quebra o conjunto em blocos com os limites renovados. Bulkifique sempre; use Batch quando o volume não cabe numa transação.

Segunda opinião técnica

Sua org cresceu. Os riscos também?

Revisão independente de automações, integrações e pontos frágeis, com prioridades claras para decidir o próximo passo.

Entender o diagnóstico →