Skip to content
February 10, 2026·Updated ·30 min read

Flow Builder vs Apex: When to Choose What?

Practical decision guide for choosing between Flow Builder and Apex for your Salesforce automation needs.

← Back

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

CriteriaFlow BuilderApex
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.

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:

  1. Flow for orchestration and high-level logic
  2. Invocable Apex for specific complex operations
  3. Apex trigger/batch only for advanced cases

This approach balances maintainability (Flow), power (Apex), and governance (separation of concerns).


Useful Resources:

Aïcha Imène DAHOUMANE
About the author
Aïcha Imène DAHOUMANE
Salesforce Administrator & Developer

Salesforce developer with a background in IT infrastructure — building reliable, maintainable solutions with Apex, Flows, LWC, and CI/CD.

ShareShare on LinkedIn