Saltar al contenido
15 de febrero de 2026·Actualizado el ·24 min de lectura

Triggers de Apex: 10 buenas prácticas para un código mantenible

Guía práctica para construir triggers de Apex robustos, eficientes y mantenibles en tus proyectos Salesforce.

← Volver

Los triggers de Apex están en el corazón de muchas automatizaciones de Salesforce. Mal diseñados, se convierten rápidamente en una fuente de errores, problemas de rendimiento y pesadillas de mantenimiento. Aquí tienes 10 buenas prácticas esenciales para desarrollar triggers profesionales.

1. Un trigger por objeto, delegación a una clase handler

❌ Mala práctica:

trigger AccountTrigger on Account (before insert, after insert, before update) {
    if (Trigger.isBefore && Trigger.isInsert) {
        // 200 líneas de lógica de negocio...
    }
    if (Trigger.isAfter && Trigger.isInsert) {
        // 150 líneas de lógica de negocio...
    }
}

✅ Buena práctica:

trigger AccountTrigger on Account (before insert, after insert, before update, after update) {
    AccountTriggerHandler.handle();
}

Delega toda la lógica a una clase handler. Esto mejora la capacidad de prueba y la legibilidad.

2. Patrón handler con métodos específicos por contexto

Estructura tu handler para gestionar claramente cada contexto:

public class AccountTriggerHandler {
    
    public static void handle() {
        if (Trigger.isBefore) {
            if (Trigger.isInsert) beforeInsert(Trigger.new);
            if (Trigger.isUpdate) beforeUpdate(Trigger.new, Trigger.oldMap);
        }
        
        if (Trigger.isAfter) {
            if (Trigger.isInsert) afterInsert(Trigger.new);
            if (Trigger.isUpdate) afterUpdate(Trigger.new, Trigger.oldMap);
        }
    }
    
    private static void beforeInsert(List<Account> newAccounts) {
        // Lógica de before insert
        AccountService.setDefaultValues(newAccounts);
        AccountService.validateBusinessRules(newAccounts);
    }
    
    private static void afterInsert(List<Account> newAccounts) {
        // Lógica de after insert
        ContactService.createPrimaryContacts(newAccounts);
    }
    
    // ... otros métodos
}

3. Separación de la lógica: patrón Service Layer

No pongas lógica de negocio en el handler. Crea clases de servicio reutilizables:

public class AccountService {
    
    public static void setDefaultValues(List<Account> accounts) {
        for (Account acc : accounts) {
            if (String.isBlank(acc.Industry)) {
                acc.Industry = 'Technology';
            }
            if (acc.AnnualRevenue == null) {
                acc.AnnualRevenue = 0;
            }
        }
    }
    
    public static void validateBusinessRules(List<Account> accounts) {
        for (Account acc : accounts) {
            if (acc.Type == 'Customer' && String.isBlank(acc.Phone)) {
                acc.addError('Phone is required for Customer accounts');
            }
        }
    }
}

Ventajas:

  • Reutilizable desde otros contextos (batch, webservice)
  • Testeable de forma aislada
  • Lógica de negocio documentada y centralizada

4. Bulkification: piensa siempre en volumen

❌ Código no bulkificado:

private static void afterInsert(List<Account> newAccounts) {
    for (Account acc : newAccounts) {
        List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id];
        // SOQL dentro de un loop = ¡PELIGRO!
    }
}

✅ Código bulkificado:

private static void afterInsert(List<Account> newAccounts) {
    Set<Id> accountIds = new Set<Id>();
    for (Account acc : newAccounts) {
        accountIds.add(acc.Id);
    }
    
    Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
    for (Contact con : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
        if (!contactsByAccount.containsKey(con.AccountId)) {
            contactsByAccount.put(con.AccountId, new List<Contact>());
        }
        contactsByAccount.get(con.AccountId).add(con);
    }
    
    // Procesamiento en bloque
}

5. Evitar la recursión infinita

Un simple booleano estático («el trigger ya se ejecutó») es una trampa clásica: cuando Data Loader o una API insertan más de 200 registros, la plataforma divide la transacción en lotes de 200 y vuelve a llamar al trigger para cada lote. Con un booleano, los lotes siguientes se ignoran en silencio. Memoriza mejor los Id ya procesados:

public class TriggerControl {
    // Clave = nombre del trigger + contexto + Id: cada registro se procesa una sola vez
    // por contexto y por transacción, sea cual sea el número de lotes de 200.
    private static Set<String> processed = new Set<String>();
 
    public static Boolean markProcessed(String triggerName, Id recordId) {
        // El contexto (BEFORE_INSERT, AFTER_UPDATE…) forma parte de la clave:
        // así un mismo registro puede pasar por before y después por after.
        return processed.add(triggerName + ':' + Trigger.operationType + ':' + recordId); // false si ya estaba
    }
}
 
// En el handler (after update):
public static void handleAfterUpdate(List<Account> newAccounts) {
    List<Account> toProcess = new List<Account>();
    for (Account acc : newAccounts) {
        if (TriggerControl.markProcessed('AccountTrigger', acc.Id)) {
            toProcess.add(acc); // primer paso para este registro
        }
    }
    if (toProcess.isEmpty()) {
        return; // todos ya procesados: se evita la recursión
    }
    // ... lógica del trigger solo sobre toProcess
}

6. Gestionar los campos modificados (before update)

Procesa solo los registros cuyos campos relevantes han cambiado:

private static void beforeUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
    List<Account> accountsToProcess = new List<Account>();
    
    for (Account newAcc : newAccounts) {
        Account oldAcc = oldMap.get(newAcc.Id);
        
        // Procesar solo si el campo Industry cambió
        if (newAcc.Industry != oldAcc.Industry) {
            accountsToProcess.add(newAcc);
        }
    }
    
    if (!accountsToProcess.isEmpty()) {
        AccountService.recalculateIndustryMetrics(accountsToProcess);
    }
}

7. Pruebas unitarias con más del 85% de cobertura

@isTest
private class AccountTriggerHandler_Test {
    
    @isTest
    static void testBeforeInsert_DefaultValues() {
        // Arrange
        List<Account> accounts = new List<Account>{
            new Account(Name = 'Test Account 1'),
            new Account(Name = 'Test Account 2', Industry = 'Healthcare')
        };
        
        // Act
        Test.startTest();
        insert accounts;
        Test.stopTest();
        
        // Assert
        List<Account> insertedAccounts = [
            SELECT Id, Industry, AnnualRevenue 
            FROM Account 
            WHERE Id IN :accounts
        ];
        
        System.assertEquals('Technology', insertedAccounts[0].Industry, 
            'Default industry should be Technology');
        System.assertEquals('Healthcare', insertedAccounts[1].Industry, 
            'Industry should remain Healthcare');
        System.assertEquals(0, insertedAccounts[0].AnnualRevenue, 
            'Default revenue should be 0');
    }
    
    @isTest
    static void testBulkInsert_200Records() {
        // Prueba con 200 registros para verificar la bulkification
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < 200; i++) {
            accounts.add(new Account(Name = 'Bulk Test ' + i));
        }
        
        Test.startTest();
        insert accounts;
        Test.stopTest();
        
        System.assertEquals(200, [SELECT COUNT() FROM Account], 
            'All 200 accounts should be inserted');
    }
}

8. Gestión explícita de errores

public static void validateBusinessRules(List<Account> accounts) {
    for (Account acc : accounts) {
        // Sin try/catch: addError no lanza ninguna excepción, marca el registro como erróneo.
        // AnnualRevenue puede estar vacío: ese caso se trata explícitamente.
        if (acc.Type == 'Customer' && (acc.AnnualRevenue == null || acc.AnnualRevenue < 10000)) {
            acc.addError('Customer accounts must have revenue >= 10,000');
        }
 
        if (acc.BillingCountry == 'France' && String.isBlank(acc.SIRET__c)) {
            acc.SIRET__c.addError('SIRET is mandatory for French companies');
        }
    }
}

9. Documentación y comentarios

/**
 * @description Handler para las operaciones del trigger de Account
 * @author Aïcha Imène DAHOUMANE
 * @date 2026-02-15
 * @group Trigger Handlers
 */
public class AccountTriggerHandler {
    
    /**
     * @description Punto de entrada principal para el trigger de Account
     * Delega a métodos específicos según el contexto del trigger
     */
    public static void handle() {
        // Implementación
    }
    
    /**
     * @description Establece valores por defecto para los nuevos registros de Account
     * - Industry por defecto a 'Technology' si está vacío
     * - AnnualRevenue por defecto a 0 si es null
     * @param newAccounts Lista de nuevos registros Account
     */
    private static void beforeInsert(List<Account> newAccounts) {
        // Implementación
    }
}

10. Logging y observabilidad

public class TriggerLogger {
    
    public static void logExecution(String triggerName, String context, Integer recordCount) {
        System.debug(LoggingLevel.INFO, 
            String.format('Trigger: {0} | Context: {1} | Records: {2}', 
            new List<String>{triggerName, context, String.valueOf(recordCount)}));
    }
    
    public static void logError(String triggerName, Exception e) {
        System.debug(LoggingLevel.ERROR, 
            String.format('Trigger: {0} | Error: {1} | Stack: {2}', 
            new List<String>{triggerName, e.getMessage(), e.getStackTraceString()}));
    }
}
 
// Uso en el handler:
public static void handle() {
    TriggerLogger.logExecution('AccountTrigger', 
        Trigger.operationType.name(), Trigger.new.size());
    
    try {
        // ... lógica del trigger
    } catch (Exception e) {
        TriggerLogger.logError('AccountTrigger', e);
        throw e;
    }
}

Checklist antes de desplegar un trigger

  • Un trigger por objeto
  • Lógica delegada a una clase handler
  • Service layer para la lógica de negocio reutilizable
  • Código 100% bulkificado (sin SOQL/DML en loops)
  • Protección contra la recursión
  • Gestión explícita de errores
  • Pruebas unitarias con más del 85% de cobertura
  • Pruebas en volumen (200+ registros)
  • Métodos públicos documentados
  • Operaciones críticas registradas (logging)

Conclusión

Los triggers de Apex bien diseñados son:

  • Eficientes: bulkification y consultas optimizadas
  • Mantenibles: código estructurado, comentado y probado
  • Fiables: gestión de errores y protección contra la recursión
  • Escalables: arquitectura modular con service layer

Aplicando estas 10 buenas prácticas, evitarás el 90% de los problemas habituales con los triggers de Apex y construirás automatizaciones robustas que resisten el paso del tiempo y la evolución del org.


Recursos útiles:

Aïcha Imène DAHOUMANE
Sobre la autora
Aïcha Imène DAHOUMANE
Administradora y Desarrolladora Salesforce

Desarrolladora Salesforce con formación en infraestructura IT — construyo soluciones fiables y mantenibles con Apex, Flows, LWC y CI/CD.

CompartirCompartir en LinkedIn