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) over code when possible. Process Builder and Workflow Rules have been out of support since December 31, 2025: any new declarative automation is built in Flow.
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 | ⚠️ HTTP Callout action for simple REST calls | ✅ Total control (authentication, transformation, retries) |
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) {
return calculatePrice(opp, System.today());
}
// The date is a parameter, so the Q4 discount can be tested on a fixed date.
// opp must be queried with Account.Years_as_Customer__c: a record that was just
// inserted has no Account relationship loaded (NullPointerException).
public static Decimal calculatePrice(Opportunity opp, Date pricingDate) {
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 (pricingDate.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.
// Database.AllowsCallouts is required to make callouts from a batch. Run with
// Database.executeBatch(new ContactUpdateBatch(), 100): 1 callout per Contact, at most 100 per transaction.
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) {
// 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 batch size control (100 records at most here, to stay under the 100-callouts-per-transaction limit)
- 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;
// Query the relationship field PricingEngine reads (otherwise NullPointerException)
opp = [SELECT Id, Amount, Account.Years_as_Customer__c FROM Opportunity WHERE Id = :opp.Id];
// Act
Test.startTest();
// Fixed date outside Q4: the result doesn't depend on the day the test runs
Decimal finalPrice = PricingEngine.calculatePrice(opp, Date.newInstance(2026, 3, 15));
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;
// Query the relationship field PricingEngine reads (otherwise NullPointerException)
opp = [SELECT Id, Amount, Account.Years_as_Customer__c FROM Opportunity WHERE Id = :opp.Id];
// Act
Test.startTest();
// Fixed date outside Q4: the result doesn't depend on the day the test runs
Decimal finalPrice = PricingEngine.calculatePrice(opp, Date.newInstance(2026, 3, 15));
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'
callout=true)
public static List<Result> validateSIRET(List<Request> requests) {
List<Result> results = new List<Result>();
// One callout per request: at most 100 per transaction, keep this in mind for large volumes
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 - 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'
Why not a before-save flow? A before-save flow only accepts the Assignment, Decision, Get Records and Loop elements: it can call neither an Apex action nor a callout. So the check runs after the save, in an asynchronous path where callouts are allowed, and its result is recorded in a status field instead of blocking the entry.
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 call, or a simple REST call (HTTP Callout action)
- < 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: