Una de las preguntas más habituales en el desarrollo Salesforce: "¿Debería usar un Flow o escribir código Apex?" Esta guía te ayuda a tomar la decisión correcta según tu contexto.
El principio "Clics, no código"
Salesforce recomienda la filosofía "Clics, no código": preferir las herramientas declarativas (Flow Builder) frente al código siempre que sea posible. Process Builder y las Workflow Rules no tienen soporte desde el 31 de diciembre de 2025: toda nueva automatización declarativa se construye en Flow.
¿Por qué?
- ✅ Desarrollo más rápido
- ✅ Mantenimiento más sencillo (interfaz visual)
- ✅ Menos tests requeridos (sin cobertura del 75%)
- ✅ Accesible para administradores sin perfil de desarrollador
Pero cuidado: este principio tiene límites. A veces el código es la mejor opción.
Matriz de decisión rápida
| Criterio | Flow Builder | Apex |
|---|---|---|
| Lógica simple | ✅ Excelente | ⚠️ Excesivo |
| Lógica compleja | ⚠️ Posible pero verboso | ✅ Excelente |
| Rendimiento crítico | ⚠️ Limitado | ✅ Optimizable |
| Reutilización | ⚠️ Limitada | ✅ Capa de servicio |
| Mantenimiento | ✅ Interfaz visual | ⚠️ Requiere perfil técnico |
| Testing | ✅ Sin cobertura requerida | ❌ 75% mínimo |
| Depuración | ⚠️ Debug Logs | ✅ Debug logs + IDE |
| Operaciones masivas | ⚠️ Cuidado con los límites | ✅ Control total |
| Integraciones externas | ⚠️ Acción HTTP Callout para llamadas REST simples | ✅ Control total (autenticación, transformación, reintentos) |
Escenarios: Flow Builder es ideal
1. Automatizaciones CRUD simples
Ejemplo: Crear automáticamente un Contacto al crear una Cuenta.
Flow: "Auto-Create Primary Contact"
Type: Record-Triggered Flow
Trigger: Account - After Save - Create
Logic:
1. Get Records: Check if Contact exists for this Account
2. Decision: Contact exists?
- No → Create Contact with default values
- Yes → End
¿Por qué Flow?
- Lógica lineal y sencilla
- Sin lógica de negocio compleja
- Visible y modificable por un administrador
2. Validaciones de negocio con mensajes de error
Ejemplo: Impedir la eliminación de una Cuenta con Oportunidades abiertas.
Flow: "Prevent Account Deletion with Open Opps"
Type: Record-Triggered Flow
Trigger: Account - Before Delete
Logic:
1. Get Records: Count Opportunities (StageName != 'Closed Won/Lost')
2. Decision: Count > 0?
- Yes → Show Error: "Cannot delete Account with open Opportunities"
- No → Allow deletion
¿Por qué Flow?
- Validación simple basada en una condición
- Mensaje de error amigable para el usuario
- No se necesita código para este caso
3. Actualización de campos calculados
Ejemplo: Calcular el importe total de Oportunidades de una Cuenta.
Flow: "Calculate Account Total Opportunity Amount"
Type: Record-Triggered Flow
Trigger: Opportunity - After Save - Create, Update, Delete
Logic:
1. Get Records: Get parent Account
2. Get Records: Sum all Opportunities.Amount for this Account
3. Update Records: Update Account.Total_Opportunity_Amount__c
¿Por qué Flow?
- Cálculo aritmético simple
- Automatización estándar
- Fácil de mantener para un administrador
4. Envío de correos transaccionales
Ejemplo: Enviar un correo de bienvenida tras la creación de un Lead.
Flow: "Welcome Email to New Leads"
Type: Record-Triggered Flow
Trigger: Lead - After Save - Create
Logic:
1. Decision: Lead Source = 'Web'?
- Yes → Send Email (Email Template: "Welcome_Lead")
- No → End
¿Por qué Flow?
- Acción nativa "Send Email" disponible
- Plantilla de correo gestionada desde Setup
- Sin complejidad que justifique código
Escenarios: Apex es necesario
1. Lógica de negocio compleja con algoritmos
Ejemplo: Cálculo dinámico de precios con descuentos progresivos.
public class PricingEngine {
public static Decimal calculatePrice(Opportunity opp) {
return calculatePrice(opp, System.today());
}
// La fecha es un parámetro: el descuento Q4 se puede probar con una fecha fija.
// opp debe consultarse con Account.Years_as_Customer__c: un registro recién
// insertado no tiene cargada la relación Account (NullPointerException).
public static Decimal calculatePrice(Opportunity opp, Date pricingDate) {
Decimal basePrice = opp.Amount;
Decimal discount = 0;
// Descuento progresivo por tramo
if (basePrice > 100000) {
discount = 0.15; // 15% por encima de 100k
} else if (basePrice > 50000) {
discount = 0.10; // 10% entre 50k-100k
} else if (basePrice > 10000) {
discount = 0.05; // 5% entre 10k-50k
}
// Descuento adicional para cliente fiel
if (opp.Account.Years_as_Customer__c > 5) {
discount += 0.05;
}
// Descuento estacional (Q4)
if (pricingDate.month() >= 10) {
discount += 0.03;
}
return basePrice * (1 - discount);
}
}¿Por qué Apex?
- Lógica con varias condiciones anidadas
- Cálculos aritméticos complejos
- Código reutilizable y testeable
- Un Flow sería demasiado verboso y difícil de mantener
2. Operaciones masivas con miles de registros
Ejemplo: Actualización por lotes de 50.000 Contactos.
// Database.AllowsCallouts es obligatorio para hacer callouts en un batch. Lanzar con
// Database.executeBatch(new ContactUpdateBatch(), 100): 1 callout por Contacto, 100 como máximo por transacción.
public class ContactUpdateBatch implements Database.Batchable<SObject>, Database.AllowsCallouts {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, MailingPostalCode, MailingCity
FROM Contact
WHERE LastModifiedDate < LAST_N_DAYS:365
]);
}
public void execute(Database.BatchableContext bc, List<Contact> scope) {
// Llamada a la API de geocodificación para cada Contacto
Map<Id, Contact> contactsToUpdate = new Map<Id, Contact>();
for (Contact con : scope) {
// Llamar al servicio externo de geocodificación
Geocoding.Result result = GeocodingService.geocode(
con.MailingPostalCode,
con.MailingCity
);
if (result.success) {
con.Geolocation__Latitude__s = result.latitude;
con.Geolocation__Longitude__s = result.longitude;
contactsToUpdate.put(con.Id, con);
}
}
if (!contactsToUpdate.isEmpty()) {
update contactsToUpdate.values();
}
}
public void finish(Database.BatchableContext bc) {
// Enviar correo de finalización
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setToAddresses(new String[] {'admin@company.com'});
mail.setSubject('Contact Geocoding Batch Completed');
mail.setPlainTextBody('Batch job finished successfully.');
Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
}
}¿Por qué Apex?
- Procesamiento de decenas de miles de registros
- Llamadas HTTP externas (API de geocodificación)
- Control fino del tamaño de los lotes (100 registros como máximo aquí, para no superar el límite de 100 callouts por transacción)
- Manejo avanzado de errores
3. Integraciones REST/SOAP complejas
Ejemplo: Integración bidireccional con un ERP externo.
public class ERPIntegrationService {
@future(callout=true)
public static void syncOrderToERP(Id orderId) {
Order order = [
SELECT Id, OrderNumber, TotalAmount, Account.Name,
(SELECT Product2.Name, Quantity, UnitPrice FROM OrderItems)
FROM Order
WHERE Id = :orderId
];
// Construir el payload JSON
Map<String, Object> payload = new Map<String, Object>{
'orderNumber' => order.OrderNumber,
'customer' => order.Account.Name,
'totalAmount' => order.TotalAmount,
'items' => buildOrderItems(order.OrderItems)
};
// Callout HTTP
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP_API/orders');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(payload));
req.setTimeout(120000);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 201) {
// Parsear la respuesta y actualizar el Order con el ID del ERP
Map<String, Object> responseData =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
order.ERP_Order_ID__c = (String) responseData.get('erpOrderId');
order.ERP_Sync_Status__c = 'Synced';
update order;
} else {
throw new ERPIntegrationException('ERP sync failed: ' + res.getBody());
}
}
private static List<Map<String, Object>> buildOrderItems(List<OrderItem> items) {
List<Map<String, Object>> result = new List<Map<String, Object>>();
for (OrderItem item : items) {
result.add(new Map<String, Object>{
'productName' => item.Product2.Name,
'quantity' => item.Quantity,
'unitPrice' => item.UnitPrice
});
}
return result;
}
}¿Por qué Apex?
- Construcción dinámica de JSON complejo
- Gestión fina de timeouts y reintentos
- Parseo estructurado de la respuesta
- Manejo de errores con excepciones personalizadas
4. Tests unitarios críticos
Ejemplo: Clase de negocio crítica que requiere cobertura del 100%.
@isTest
private class PricingEngine_Test {
@isTest
static void testCalculatePrice_BaseDiscount() {
// Arrange
Account acc = new Account(Name = 'Test Corp', Years_as_Customer__c = 3);
insert acc;
Opportunity opp = new Opportunity(
Name = 'Test Deal',
AccountId = acc.Id,
Amount = 60000,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
);
insert opp;
// Consultar el campo de relación que lee PricingEngine (si no, NullPointerException)
opp = [SELECT Id, Amount, Account.Years_as_Customer__c FROM Opportunity WHERE Id = :opp.Id];
// Act
Test.startTest();
// Fecha fija fuera de Q4: el resultado no depende del día en que se ejecuta el test
Decimal finalPrice = PricingEngine.calculatePrice(opp, Date.newInstance(2026, 3, 15));
Test.stopTest();
// Assert
Decimal expected = 60000 * 0.90; // 10% de descuento
System.assertEquals(expected, finalPrice,
'Price should have 10% discount for amount between 50k-100k');
}
@isTest
static void testCalculatePrice_LoyalCustomer() {
// Arrange
Account acc = new Account(Name = 'Loyal Corp', Years_as_Customer__c = 7);
insert acc;
Opportunity opp = new Opportunity(
Name = 'Test Deal',
AccountId = acc.Id,
Amount = 60000,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
);
insert opp;
// Consultar el campo de relación que lee PricingEngine (si no, NullPointerException)
opp = [SELECT Id, Amount, Account.Years_as_Customer__c FROM Opportunity WHERE Id = :opp.Id];
// Act
Test.startTest();
// Fecha fija fuera de Q4: el resultado no depende del día en que se ejecuta el test
Decimal finalPrice = PricingEngine.calculatePrice(opp, Date.newInstance(2026, 3, 15));
Test.stopTest();
// Assert
Decimal expected = 60000 * 0.85; // 10% base + 5% fidelidad
System.assertEquals(expected, finalPrice,
'Loyal customer should get additional 5% discount');
}
}¿Por qué Apex?
- Tests con aserciones precisas
- Control total del contexto de test
- Validación de cada rama de la lógica
- Cobertura de código requerida
Enfoque híbrido: lo mejor de ambos mundos
En muchos casos, la mejor solución es una combinación de Flow + Apex.
Patrón recomendado: el Flow llama a Apex
Ejemplo: Validación de un número SIRET francés con una API externa.
1. Crear una clase Apex invocable:
public class SIRETValidator {
@InvocableMethod(label='Validate SIRET'
description='Validates French SIRET number via external API'
callout=true)
public static List<Result> validateSIRET(List<Request> requests) {
List<Result> results = new List<Result>();
// Un callout por solicitud: 100 como máximo por transacción, a tener en cuenta con grandes volúmenes
for (Request req : requests) {
Result result = new Result();
result.isValid = callSIRETAPI(req.siretNumber);
results.add(result);
}
return results;
}
private static Boolean callSIRETAPI(String siret) {
// Callout HTTP a la API SIRENE
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:SIRENE_API/siret/' + siret);
req.setMethod('GET');
Http http = new Http();
HttpResponse res = http.send(req);
return res.getStatusCode() == 200;
}
public class Request {
@InvocableVariable(required=true)
public String siretNumber;
}
public class Result {
@InvocableVariable
public Boolean isValid;
}
}2. Llamarla desde un Flow:
Flow: "Validate Account SIRET"
Type: Record-Triggered Flow
Trigger: Account - After Save - Create, Update
Path: Run Asynchronously
Logic:
1. Decision: Country = 'France' AND SIRET changed?
- Yes → Continue
- No → End
2. Action: Apex - SIRETValidator.validateSIRET
Input: SIRET__c
Output: isValid
3. Update Records: SIRET_Status__c = isValid ? 'Valid' : 'Invalid'
¿Por qué no un flow before-save? Un flow before-save solo admite los elementos Assignment, Decision, Get Records y Loop: no puede llamar ni a una acción Apex ni a un callout. Por eso la verificación se hace después de guardar, en una ruta asíncrona donde los callouts están permitidos, y su resultado se registra en un campo de estado en lugar de bloquear la entrada.
Ventajas:
- ✅ Lógica de Flow simple y legible
- ✅ Complejidad técnica aislada en Apex
- ✅ Testeable (tests Apex para la llamada a la API)
- ✅ Mantenible (el admin puede modificar el Flow, el dev se encarga de la API)
Checklist de decisión
Usa esta checklist para elegir:
Prefiere Flow Builder si:
- La lógica es lineal (< 10 pasos)
- No hay algoritmos complejos
- No hay llamadas HTTP externas, o solo una llamada REST simple (acción HTTP Callout)
- Se procesan < 1.000 registros
- El mantenimiento lo hacen administradores sin perfil de desarrollador
Prefiere Apex si:
- Lógica con múltiples condiciones anidadas
- Cálculos matemáticos complejos
- Integraciones REST/SOAP
- Procesamiento masivo (> 10.000 registros)
- Reutilización crítica (capa de servicio)
- Rendimiento crítico
- Se requieren tests unitarios avanzados
Enfoque híbrido (Flow + Apex invocable) si:
- Lógica global simple PERO con un paso complejo
- Necesitas llamar a una API externa a mitad del Flow
- Mantenimiento compartido entre admin y desarrollador
Conclusión
La regla de oro: empieza siempre evaluando si Flow Builder es suficiente. Si identificas una limitación clara (rendimiento, complejidad, integración), pasa entonces a Apex.
Patrón recomendado:
- Flow para la orquestación y la lógica de alto nivel
- Apex invocable para operaciones específicas complejas
- Trigger/batch Apex solo para casos avanzados
Este enfoque equilibra mantenibilidad (Flow), potencia (Apex) y gobernanza (separación de responsabilidades).
Recursos útiles: