One of the most common questions in Salesforce development: "Should I use a Flow or write Apex code?" This guide helps you make the right decision based on your context.
The "Clicks, not Code" Principle
Salesforce recommends the "Clicks, not Code" philosophy: prefer declarative tools (Flow Builder, Process Builder) over code when possible.
Why?
- ✅ Faster development
- ✅ Easier maintenance (visual interface)
- ✅ Fewer tests required (no 75% coverage)
- ✅ Accessible to non-developer admins
But beware: this principle has limits. Sometimes code is the best option.
Quick Decision Matrix
| Criteria | Flow Builder | Apex | |----------|-------------|------| | Simple logic | ✅ Excellent | ⚠️ Overkill | | Complex logic | ⚠️ Possible but verbose | ✅ Excellent | | Critical performance | ⚠️ Limited | ✅ Optimizable | | Reusability | ⚠️ Limited | ✅ Service layer | | Maintenance | ✅ Visual interface | ⚠️ Requires dev skills | | Testing | ✅ No coverage required | ❌ 75% minimum | | Debugging | ⚠️ Debug Logs | ✅ Debug logs + IDE | | Bulk operations | ⚠️ Watch limits | ✅ Full control | | External integrations | ❌ Limited HTTP callouts | ✅ Total control |
Scenarios: Flow Builder is Ideal
1. Simple CRUD Automations
Example: Auto-create a Contact when creating an Account.
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
Why Flow?
- Linear and simple logic
- No complex business logic
- Visible and modifiable by an admin
2. Business Validations with Error Messages
Example: Prevent deletion of Account with open Opportunities.
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
Why Flow?
- Simple validation based on condition
- User-friendly error message
- No code needed for this case
3. Calculated Field Updates
Example: Calculate total Opportunity amount for an Account.
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
Why Flow?
- Simple arithmetic calculation
- Standard automation
- Easily maintained by an admin
4. Transactional Email Sending
Example: Send welcome email after Lead creation.
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
Why Flow?
- Native "Send Email" action available
- Email template managed via Setup
- No complexity justifying code
Scenarios: Apex is Required
1. Complex Business Logic with Algorithms
Example: Dynamic pricing calculation with progressive discounts.
public class PricingEngine {
public static Decimal calculatePrice(Opportunity opp) {
Decimal basePrice = opp.Amount;
Decimal discount = 0;
// Progressive discount by tier
if (basePrice > 100000) {
discount = 0.15; // 15% above 100k
} else if (basePrice > 50000) {
discount = 0.10; // 10% between 50k-100k
} else if (basePrice > 10000) {
discount = 0.05; // 5% between 10k-50k
}
// Additional discount for loyal customer
if (opp.Account.Years_as_Customer__c > 5) {
discount += 0.05;
}
// Seasonal discount (Q4)
if (System.today().month() >= 10) {
discount += 0.03;
}
return basePrice * (1 - discount);
}
}Why Apex?
- Logic with multiple nested conditions
- Complex arithmetic calculations
- Reusable and testable code
- Flow would be too verbose and hard to maintain
2. Bulk Operations with Thousands of Records
Example: Batch update of 50,000 Contacts.
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) {
// Geocoding API call for each Contact
Map<Id, Contact> contactsToUpdate = new Map<Id, Contact>();
for (Contact con : scope) {
// Call external geocoding service
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) {
// Send completion email
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 });
}
}Why Apex?
- Processing tens of thousands of records
- External HTTP calls (geocoding API)
- Fine-grained chunking control (200 records/batch)
- Advanced error handling
3. Complex REST/SOAP Integrations
Example: Bidirectional integration with external ERP.
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
];
// Construct JSON payload
Map<String, Object> payload = new Map<String, Object>{
'orderNumber' => order.OrderNumber,
'customer' => order.Account.Name,
'totalAmount' => order.TotalAmount,
'items' => buildOrderItems(order.OrderItems)
};
// HTTP Callout
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) {
// Parse response and update Order with ERP ID
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;
}
}Why Apex?
- Dynamic construction of complex JSON
- Fine timeout and retry management
- Structured response parsing
- Error handling with custom exceptions
4. Critical Unit Testing
Example: Critical business class requiring 100% tests.
@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% discount
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% loyalty
System.assertEquals(expected, finalPrice,
'Loyal customer should get additional 5% discount');
}
}Why Apex?
- Tests with precise assertions
- Full control of test context
- Validation of each logic branch
- Code coverage required
Hybrid Approach: Best of Both Worlds
In many cases, the best solution is a combination of Flow + Apex.
Recommended Pattern: Flow Calls Apex
Example: French SIRET validation with external API.
1. Create an invocable Apex class:
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) {
// HTTP Callout to SIRENE API
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. Call from a 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
Benefits:
- ✅ Simple and readable Flow logic
- ✅ Technical complexity isolated in Apex
- ✅ Testable (Apex tests for API call)
- ✅ Maintainable (admin can modify Flow, dev handles API)
Decision Checklist
Use this checklist to choose:
Prefer Flow Builder if:
- [ ] Logic is linear (< 10 steps)
- [ ] No complex algorithms
- [ ] No external HTTP calls
- [ ] < 1,000 records processed
- [ ] Maintenance by non-developer admins
Prefer Apex if:
- [ ] Logic with multiple nested conditions
- [ ] Complex mathematical calculations
- [ ] REST/SOAP integrations
- [ ] Bulk processing (> 10,000 records)
- [ ] Critical reusability (service layer)
- [ ] Critical performance
- [ ] Advanced unit tests required
Hybrid approach (Flow + invocable Apex) if:
- [ ] Overall simple logic BUT one complex step
- [ ] Need to call external API mid-Flow
- [ ] Shared admin/developer maintenance
Conclusion
The golden rule: Always start by evaluating if Flow Builder is sufficient. If you identify a clear limitation (performance, complexity, integration), then move to Apex.
Recommended pattern:
- Flow for orchestration and high-level logic
- Invocable Apex for specific complex operations
- Apex trigger/batch only for advanced cases
This approach balances maintainability (Flow), power (Apex), and governance (separation of concerns).
Useful Resources: