Apex Academy
Módulo 6·05·35 min

Recursión y cómo evitarla

Un trigger que guarda registros de su propio objeto se vuelve a disparar a sí mismo. Por qué pasa, cómo reconocerlo y las tres defensas, de mejor a peor.

En Admin esUn Flow que actualiza el mismo registro que lo disparó

✓ En esta sub-lección

  1. 01Explicar qué provoca la recursión en un trigger y qué error produce.
  2. 02Evitarla de raíz eligiendo before y comprobando si el campo cambió.
  3. 03Escribir una guarda con un Set<Id> estático, y saber por qué un Boolean estático no basta.

La nutria: ¿Te acuerdas? · Repaso de la lección 4

¿Qué corre antes: tu trigger before o la regla de validación?

En la lección 3 viste un aviso: hacer update de los propios registros desde un trigger after es mala idea. Aquí ves por qué. Ese update es un guardado como cualquier otro, recorre el orden de ejecución entero y vuelve a disparar el mismo trigger. Que vuelve a hacer update. Que lo vuelve a disparar. Eso es la .

La nutria: El paralelo de Admin

Esto me pasó con un flow: hice un Record-Triggered Flow «Actions and Related Records» que actualizaba el mismo registro que lo disparó, y ese Update Records volvía a guardar el registro y relanzaba la automatización del objeto. Por eso Salesforce recomienda cambiar el propio registro con «Fast Field Updates». Y en los tiempos de Process Builder, «Allow process to evaluate a record multiple times in a single transaction» era exactamente esta pregunta. En un trigger no hay casilla ni aviso: la defensa la escribes tú.

Cómo se ve

Apex
trigger OpportunityReview on Opportunity (after update) {
    List<Opportunity> toUpdate = new List<Opportunity>();
    for (Opportunity o : Trigger.new) {
        toUpdate.add(new Opportunity(Id = o.Id, Description = 'Revisada'));
    }
    update toUpdate;   // ← guarda oportunidades… y dispara este trigger otra vez
}
Bulkificado, sin consultas en bucles… y aun así roto. La recursión no es un problema de volumen: aparece con un solo registro.

TRIGGERS ANIDADOS

nivel 1 / 16

  1. Nivel 1 · Ventas pasa Acme a 'Negotiation' → after update → update toUpdate
Salesforce corta el bucle al llegar a 16 niveles, y entonces se deshace toda la transacción.

Salesforce no deja que el bucle siga para siempre: al llegar a 16 niveles de triggers anidados lanza «Maximum trigger depth exceeded» y la transacción entera se deshace. Antes de llegar ahí, cada vuelta ha gastado CPU, consultas y DML del mismo presupuesto del Módulo 4, así que a menudo el error que ves es otro governor limit. En los dos casos el usuario solo ve que no puede guardar.

Defensa 1: no provocarla

La mayoría de las recursiones nacen de cambiar el propio registro en after. Si lo que quieres es cambiar un campo del mismo registro, hazlo en before: se guarda solo, sin DML, y sin DML no hay nuevo guardado que dispare nada. El ejemplo de arriba, reescrito, desaparece como problema.

Apex
trigger OpportunityReview on Opportunity (before update) {
    for (Opportunity o : Trigger.new) {
        o.Description = 'Revisada';   // sin DML: no hay segunda vuelta
    }
}

Defensa 2: actuar solo si algo cambió

Hay casos en los que after es inevitable: por ejemplo, cuando una oportunidad cambia de etapa y tienes que actualizar la cuenta, y la cuenta a su vez tiene un trigger que toca sus oportunidades. Ahí la segunda defensa es la comparación de la lección 2: actúa solo si el campo que te importa cambió respecto a Trigger.oldMap. En la segunda vuelta el campo ya no cambia, y la lógica no se vuelve a ejecutar. Es lo que en Flow hace «Only when a record is updated to meet the condition requirements».

Defensa 3: la guarda estática

Cuando no puedes evitar el DML ni basta con comparar, apuntas qué registros ya procesaste en esta transacción. En el Módulo 5 viste que una variable static vive exactamente una transacción: nace con el primer uso y desaparece al terminar. Es el sitio perfecto para esa lista, en una pequeña clase auxiliar.

Apex
public class OpportunityTriggerGuard {
    public static Set<Id> processed = new Set<Id>();
}

trigger OpportunityReview on Opportunity (after update) {
    List<Opportunity> toUpdate = new List<Opportunity>();
    for (Opportunity o : Trigger.new) {
        if (OpportunityTriggerGuard.processed.contains(o.Id)) {
            continue;                                  // ya lo hice en esta transacción
        }
        OpportunityTriggerGuard.processed.add(o.Id);
        toUpdate.add(new Opportunity(Id = o.Id, Description = 'Revisada'));
    }
    if (!toUpdate.isEmpty()) {
        update toUpdate;   // la segunda vuelta encuentra todos los Ids y no hace nada
    }
}
Son dos archivos: la clase y el trigger. El continue del Módulo 2 salta los ya procesados, y el if de la lista vacía evita un DML inútil.

La nutria: Cómo se nota una recursión desde fuera

Antes de leer ningún código, yo sospecho de recursión por sus síntomas: tareas o notificaciones duplicadas, un campo con varias entradas idénticas seguidas en el historial de campos (Field History Tracking), o el error «Maximum trigger depth exceeded» en una carga de datos. Si ves cualquiera de los tres, busca un trigger (o un flow) que guarde registros de su propio objeto.

La nutria: ¿Y por qué no un Flow? Porque Flow no tiene memoria entre vueltas

Este sí que no tenía arreglo limpio en Flow. Cada interview empieza de cero: no hay nada como un static Set<Id> que recuerde «esta oportunidad ya la procesé» durante toda la transacción. La salida habitual es un campo casilla en el registro que haga de bandera, y eso significa un campo más en el objeto y un guardado más para marcarlo y desmarcarlo. La guía de Salesforce lo dice tal cual: la memoria de la transacción con variables static es terreno de Apex.