Skip to content
Back to Insights
Salesforce HubSpot Apex Workflows

Salesforce Apex vs HubSpot Workflows: Picking Your Custom Logic Layer

Both Salesforce and HubSpot give you a way to run custom logic, but the constraints couldn't be more different. This post breaks down where each platform's customization layer breaks down and how to design around the limits.

Codecanis Admin

9 min read
Engineering team whiteboard session
Architecture session for a CRM extension serving 12K sales reps.

Every CRM platform eventually has the same conversation: a stakeholder asks for "just one small piece of custom logic," and the engineering team has to choose between the platform's native customisation layer (Apex on Salesforce, Workflows + Custom Code on HubSpot) and an external service. Pick wrong, and what looks like a clean automation becomes a maintenance liability that surfaces every quarter.

This post compares the two platforms' customisation layers, the limits that determine when each breaks down, and the design patterns we use to keep custom logic maintainable at scale.

Salesforce: Apex, Flow, and the Governor Limits

Salesforce gives you two primary customisation surfaces: Flow (a declarative, no-code automation builder) and Apex (a Java-like programming language that runs server-side). Both execute inside a sandboxed runtime with a notoriously strict set of constraints called governor limits.

The most important governor limits to internalise:

  • 100 SOQL queries per transaction.
  • 150 DML statements per transaction (each insert/update/delete counts; bulk operations count as one regardless of record count).
  • 10,000 record rows queried per transaction.
  • 10 seconds CPU time for synchronous transactions, 60 seconds for async.
  • 6MB heap size synchronous, 12MB async.

These limits make some patterns natural and others impossible. The pattern that works:

// Bulkified trigger handler — processes whole collections, not individual records
public class OpportunityTriggerHandler {

    public static void afterUpdate(List<Opportunity> newOpps,
                                    Map<Id, Opportunity> oldOppsById) {
        Set<Id> accountIds = new Set<Id>();

        for (Opportunity opp : newOpps) {
            Opportunity oldOpp = oldOppsById.get(opp.Id);
            if (opp.StageName == 'Closed Won' && oldOpp.StageName != 'Closed Won') {
                accountIds.add(opp.AccountId);
            }
        }

        if (accountIds.isEmpty()) return;

        // ONE query for all accounts — not one per opportunity
        List<Account> accountsToUpdate = [
            SELECT Id, Total_Won_Revenue__c
            FROM Account
            WHERE Id IN :accountIds
        ];

        Map<Id, Decimal> revenueByAccount = aggregateRevenue(accountIds);

        for (Account a : accountsToUpdate) {
            a.Total_Won_Revenue__c = revenueByAccount.get(a.Id);
        }

        // ONE DML for all updates — not one per account
        update accountsToUpdate;
    }
}

The anti-pattern — issuing a SOQL query or a DML statement inside a loop — will work fine in a sandbox with five records and fail in production the moment a sales op does a bulk import. We have seen this break revenue-critical automation.

Flow vs Apex: The Decision Tree

Salesforce's official guidance is "use Flow first, Apex only when Flow can't do it." That's correct but imprecise. Our rule:

  • Use Flow when: the logic is record-level, the steps are sequential, the conditions are simple, and the operation completes in under 5 elements. Examples: setting a field based on another field, sending an email, creating a child record.
  • Use Apex when: you need recursion, complex collection manipulation, callouts to external services with retry logic, or any kind of bulk processing. Also: anything that needs unit tests with proper assertions (Apex has a robust test framework; Flow's testing is much weaker).
  • Use both: Apex invocable methods called from Flow gives you Flow's declarative orchestration with Apex's expressiveness for the hard parts.

The Apex Trigger Framework Pattern

Trigger frameworks are a religion in the Salesforce community. The point isn't elegance — it's preventing the "fifteen triggers on the same object, all firing in undefined order" disaster. We use a single trigger per object that delegates to a handler class:

// OpportunityTrigger.trigger — the only trigger on Opportunity
trigger OpportunityTrigger on Opportunity (
    before insert, before update, before delete,
    after insert, after update, after delete, after undelete
) {
    new OpportunityTriggerHandler().run();
}

Combine with a recursion guard (a static Set of record IDs already processed in this transaction) to prevent infinite loops from trigger-on-trigger cascades.

HubSpot: Workflows and Custom Code Actions

HubSpot's customisation surface is structured differently. The primary tool is the Workflow — a visual automation builder roughly equivalent to Salesforce Flow. Inside a workflow, you can add Custom Code actions, which are short Node.js or Python functions that execute in a managed runtime.

The constraints are different from Apex but no less restrictive:

  • 20-second execution timeout for custom code actions.
  • 128MB memory limit.
  • 4 outbound HTTP calls per execution (Operations Hub Enterprise raises this to 12).
  • 10,000 custom code executions per day on Operations Hub Pro, 200,000 on Enterprise.
  • No native scheduling beyond what workflows give you — and workflows are event-triggered, not cron-triggered.
// HubSpot custom code action — Node.js runtime
const axios = require('axios');

exports.main = async (event, callback) => {
  const dealId = event.inputFields['dealId'];
  const dealAmount = parseFloat(event.inputFields['amount']);

  // Outbound call counted against the 4-call limit
  const response = await axios.get(
    `https://api.internal.com/credit-check?dealId=${dealId}`,
    {
      headers: { 'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}` },
      timeout: 5000,
    }
  );

  const riskScore = response.data.score;
  const requiresApproval = riskScore > 0.7 || dealAmount > 50000;

  callback({
    outputFields: {
      riskScore: riskScore,
      requiresApproval: requiresApproval,
      approvalReason: requiresApproval
        ? `Risk: ${riskScore}, Amount: $${dealAmount}`
        : null,
    },
  });
};

The 4-call HTTP limit is the single most common reason teams escape to external services. Anything that requires calling more than a handful of external APIs in a single workflow step needs to live outside HubSpot.

When to Escape to an External Service

Both platforms have a ceiling. When you hit it, the right pattern is almost always the same: push the work to an external service and use the CRM's native customisation layer only to trigger it.

Signals that you've outgrown the in-platform layer:

  • You're approximating control flow (loops, retries, fan-out/fan-in) by chaining workflows.
  • You have business logic duplicated between Apex and an external service.
  • You need to test logic with a real test framework (assertions, mocks, fixtures) rather than the platform's limited testing.
  • You're hitting governor limits regularly enough that you're refactoring code to avoid them rather than to make it better.
  • The logic depends on data not in the CRM (proprietary scoring models, real-time integrations, complex calculations).

The architecture for the external pattern: a thin trigger in the CRM (Apex trigger or HubSpot webhook workflow) calls out to your service. Your service does the real work. On completion, it writes back to the CRM via the REST/GraphQL API.

This adds latency (50–500ms for the round trip) but gives you a real programming environment — version control, proper tests, your own observability, the languages and libraries you're already using. For anything beyond trivial logic, the trade-off is worth it.

Salesforce vs HubSpot: Honest Comparison

  • Programming power: Apex wins. It's a real language with collections, classes, and a meaningful type system. HubSpot custom code is Node/Python with a tight execution envelope.
  • Testing: Apex wins. The platform requires 75% test coverage to deploy code, and the test framework is genuinely good.
  • Ease of use: HubSpot wins, by a lot. Workflows are dramatically more approachable than Salesforce Flow, and custom code actions are quicker to write than Apex.
  • Governor limits: Salesforce limits are stricter but better documented; you can design around them. HubSpot limits are looser but the 4-call HTTP limit catches teams by surprise.
  • Escape velocity: Both platforms make external integration straightforward. Salesforce's REST/Bulk API is more mature; HubSpot's API is more pleasant to use day-to-day.

Key Takeaways

  • Salesforce governor limits force bulkified design from day one. Embrace it; the patterns are well-established.
  • Use Salesforce Flow for simple record-level automation, Apex for anything with collections, recursion, or external callouts.
  • One trigger per object, delegating to a handler class. Always.
  • HubSpot custom code actions hit the 4-call HTTP limit fast — design for an external service when you cross that threshold.
  • Externalise business logic to a real service when you're approximating control flow with chained workflows or duplicating logic across platforms.
  • Trigger from the CRM, do the work in your own service, write results back via API. That pattern scales further than either platform's native code layer.
Let's build something

Want to work together?

If this article made you think about your architecture, your roadmap, or a problem you haven't solved yet — let's talk.