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, Process Builder) frente al código siempre que sea posible.
¿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 | ❌ Callouts HTTP limitados | ✅ Control total |
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) {
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 (System.today().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.
public class ContactUpdateBatch implements Database.Batchable<SObject> {
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 chunks (200 registros/lote)
- 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;
// Act
Test.startTest();
Decimal finalPrice = PricingEngine.calculatePrice(opp);
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;
// Act
Test.startTest();
Decimal finalPrice = PricingEngine.calculatePrice(opp);
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')
public static List<Result> validateSIRET(List<Request> requests) {
List<Result> results = new List<Result>();
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 - Before Save - Create, Update
Logic:
1. Decision: Country = 'France' AND SIRET changed?
- Yes → Continue
- No → End
2. Action: Apex - SIRETValidator.validateSIRET
Input: SIRET__c
Output: isValid
3. Decision: isValid = false?
- Yes → Show Error: "Invalid SIRET number"
- No → Allow save
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
- [ ] 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: