agentleFS
grzmolgrzmol/vibe-force/skills/sf-integration-patterns/SKILL.md

sf-integration-patterns

Skill2 starsChanged 2 months agoMITView on GitHub

Salesforce integration design and implementation - selecting an integration pattern (Remote Process Invocation request-reply and fire-and-forget, Batch Data Synchronisation, Remote Call-In, UI Update Based on Data Changes, Data Virtualisation), building inbound APIs (REST API, composite/composite-graph/batch/tree, sObject Collections, Bulk API 2.0, SOAP, Apex REST @RestResource, GraphQL API, Pub/Sub API, Streaming/CometD), and building outbound integrations (Apex HttpRequest callouts, Continuation, Named Credentials and External Credentials, External Services, Outbound Messages, Change Data Capture, Salesforce Connect external objects). Use this skill when a story involves callouts, webhooks, platform events, named credentials, external credentials, middleware boundaries, retry and idempotency design, callout mocking with HttpCalloutMock and Test.setMock, or probing a real org with sf api request rest. Also use it when reviewing integration code for transaction rules (no callout after DML), callout governor limits, secret handling, or dead-letter and correlation-id observability.

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Never hardcode a URL or a secret.

    line 56
  2. Distinctive:

    Prefer standard REST API and composite resources over custom Apex REST.

    line 185
  3. Very specific:

    Never point a callout smoke probe at a production third-party endpoint.

    line 283

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Salesforce Integration Patterns

Pattern selection, inbound/outbound mechanics, retry and idempotency, and the verification commands that prove an integration works. Owned in wave 1 by sf-integration-engineer (paths: namedCredentials/, externalCredentials/, externalServiceRegistrations/, platformEvents objects, integration Apex services). See skill sf-workflow-orchestration for the wave model and .vibeforce/state/contract.md conventions.

When to use

TriggerUse this skill for
Story says "call system X", "webhook", "sync from ERP"Pattern selection table below, then references/pattern-catalogue.md
New endpoint to consumereferences/named-credentials.md + references/callout-and-retry.md
External system must write into Salesforcereferences/inbound-apis.md (limits, idempotency, composite)
Event-driven work (platform events, CDC, Pub/Sub)Pattern 2 + 5 below, references/pattern-catalogue.md
Callout code reviewAnti-patterns section, references/callout-and-retry.md
Tests for integration codereferences/integration-testing.md, skill sf-apex-testing
Verifying a deployed integration in a real orgVerification section, skill sf-post-deploy-verification

Pattern selection

Six archetype patterns from Salesforce's Integration Patterns and Practices. Pick on latency, volume, coupling, and transactionality - not on familiarity.

PatternDirectionTimingVolumeTransactional?Primary mechanism
Remote Process Invocation - Request and ReplySF -> remoteSynchronousSmall, real timeResponse processed in the same SF transactionExternal Services / Flow HTTP Callout / Apex HttpRequest (+ Continuation for slow endpoints)
Remote Process Invocation - Fire and ForgetSF -> remoteAsynchronousSmall per messageNo; publisher does not waitPlatform event (Flow or Apex) consumed via Pub/Sub API; Event Relay to AWS; @future/Queueable callout
Batch Data SynchronisationBothAsynchronous, scheduledLarge (>2,000 records)Per-batch onlyETL/MuleSoft + Bulk API 2.0; Change Data Capture for SF-as-master
Remote Call-Inremote -> SFSynchronous request-replySmall to medium; Bulk for largePer call; composite gives all-or-noneREST API, Composite, Composite Graph, sObject Collections, Apex REST, GraphQL API, Bulk API 2.0, SOAP API
UI Update Based on Data ChangesSF -> SF UINear real timeLowNoPlatform event published by trigger/Flow, LWC empApi subscriber, Pub/Sub API for external UI
Data VirtualisationSF -> remote (read/write)SynchronousSmall per pageNoSalesforce Connect external objects (OData 2.0/4.0, cross-org, Apex Connector Framework)

Decision shortcuts:

QuestionAnswer
User waits for the answer?Request and Reply. If the endpoint can be slow, use Continuation (LWC) - max 3 callouts per continuation, no DML in the continuation method.
Record change must notify N unknown subscribers?Fire and Forget with a platform event; subscribers use Pub/Sub API (gRPC/HTTP2, Avro).
>2,000 records per run?Bulk API 2.0 ingest, driven by middleware, not Apex callouts.
Remote system must create/update many related records atomically?Composite (25 subrequests, allOrNone) or Composite Graph (500 nodes per payload).
Data must not be copied into Salesforce?Data Virtualisation with Salesforce Connect.
Orchestration, protocol conversion, cross-system transactions?Middleware (MuleSoft/Informatica). Salesforce cannot participate in distributed transactions.
Legacy option on the table (Outbound Messaging, Streaming API/CometD, SOAP login())?Replace: Flow + platform events, Pub/Sub API, OAuth 2.0. login() is unavailable in API v65.0+ and retires Summer '27.

Core patterns

1. Request and reply with a Named Credential

Never hardcode a URL or a secret. The endpoint lives in a NamedCredential; the authentication lives in an ExternalCredential; users are authorised through a permission set mapped to an external credential principal. See references/named-credentials.md.

public with sharing class BillingGateway {
    public class GatewayException extends Exception {}

    public static BillingAccount createAccount(Id accountId, String correlationId) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Billing_API/v1/accounts');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setHeader('X-Correlation-Id', correlationId);
        req.setHeader('Idempotency-Key', correlationId);
        req.setTimeout(20000); // ms; default 10000, max 120000
        req.setBody(JSON.serialize(new Map<String, Object>{
            'salesforceId' => accountId,
            'requestedAt' => Datetime.now()
        }));

        HttpResponse res = new Http().send(req);
        if (res.getStatusCode() == 200 || res.getStatusCode() == 201) {
            return (BillingAccount) JSON.deserialize(res.getBody(), BillingAccount.class);
        }
        throw new GatewayException(
            'Billing_API ' + res.getStatusCode() + ' ' + res.getStatus() + ': ' + res.getBody()
        );
    }

    public class BillingAccount {
        public String id;
        public String status;
    }
}

Rules enforced in review: callout: endpoint only, explicit timeout, status code checked before deserialisation, correlation id propagated, exception carries the endpoint name and status.

2. Fire and forget with a platform event

Publish behaviour matters. Publish After Commit only publishes when the transaction commits - choose it when subscribers read data written by the publisher. Publish Immediately publishes even if the transaction later fails - choose it for logging-style events.

public with sharing class OrderEventPublisher {
    public static void publish(List<Order> orders) {
        List<Order_Submitted__e> events = new List<Order_Submitted__e>();
        for (Order o : orders) {
            events.add(new Order_Submitted__e(
                Order_Id__c = o.Id,
                Correlation_Id__c = IntegrationContext.newCorrelationId(),
                Payload__c = JSON.serialize(new Map<String, Object>{
                    'accountId' => o.AccountId, 'total' => o.TotalAmount
                })
            ));
        }
        List<Database.SaveResult> results = EventBus.publish(events);
        for (Integer i = 0; i < results.size(); i++) {
            if (!results[i].isSuccess()) {
                for (Database.Error e : results[i].getErrors()) {
                    IntegrationLog.error('Order_Submitted__e', events[i].Correlation_Id__c, e.getMessage());
                }
            }
        }
    }
}

Platform events are not rolled back by the publishing transaction and are published to the bus once; Salesforce does not retry on the publisher side. High-volume event messages are retained for 72 hours and are replayable by Replay ID. Max event message size is 1 MB.

3. Retryable subscriber with bounded retries

Apex subscribers signal "redeliver this batch" with EventBus.RetryableException. A platform event trigger runs at most 10 times for a batch sequence (1 run + 9 retries); after the 9th retry the trigger enters an error state and stops consuming new events until the trigger is re-saved.

trigger OrderSubmittedTrigger on Order_Submitted__e (after insert) {
    try {
        OrderSubmittedHandler.handle(Trigger.new);
    } catch (CalloutException | QueryException transientError) {
        if (EventBus.TriggerContext.currentContext().retries < 9) {
            throw new EventBus.RetryableException(transientError.getMessage());
        }
        OrderSubmittedHandler.deadLetter(Trigger.new, transientError); // persists + alerts
    }
}

DML performed before the RetryableException is rolled back; the whole batch is redelivered in Replay ID order. Anything that cannot be retried goes to a dead-letter object - see references/callout-and-retry.md.

4. Callouts from an asynchronous context

Callouts are forbidden after DML in the same transaction and forbidden altogether in a trigger's synchronous context. Move them to Queueable (preferred) or @future(callout=true).

public with sharing class BillingSyncJob implements Queueable, Database.AllowsCallouts {
    private final List<Id> accountIds;
    private final Integer attempt;

    public BillingSyncJob(List<Id> accountIds) { this(accountIds, 0); }
    public BillingSyncJob(List<Id> accountIds, Integer attempt) {
        this.accountIds = accountIds;
        this.attempt = attempt;
    }

    public void execute(QueueableContext ctx) {
        System.attachFinalizer(new BillingSyncFinalizer(accountIds, attempt));
        for (Id accountId : accountIds) {
            BillingGateway.createAccount(accountId, IntegrationContext.newCorrelationId());
        }
    }
}

System.attachFinalizer gives a retry/dead-letter hook that runs in its own transaction even when the job dies from an unhandled exception. Full engine with exponential backoff: references/callout-and-retry.md. Async limits and chaining rules: skill sf-async-apex-patterns.

5. Inbound API for a remote caller

Prefer standard REST API and composite resources over custom Apex REST. Write Apex REST only when you need multi-object transactionality or custom pre-commit logic.

@RestResource(urlMapping='/order-sync/*')
global with sharing class OrderSyncResource {
    @HttpPost
    global static ResponsePayload upsertOrder(String externalId, String status) {
        RestResponse res = RestContext.response;
        if (String.isBlank(externalId)) {
            res.statusCode = 400;
            return new ResponsePayload('error', 'externalId is required');
        }
        Order__c record = new Order__c(External_Id__c = externalId, Status__c = status);
        // Upsert on the external id makes repeated calls idempotent.
        Database.UpsertResult result = Database.upsert(record, Order__c.External_Id__c, false, AccessLevel.USER_MODE);
        if (!result.isSuccess()) {
            res.statusCode = 400;
            return new ResponsePayload('error', result.getErrors()[0].getMessage());
        }
        res.statusCode = result.isCreated() ? 201 : 200;
        return new ResponsePayload('ok', result.getId());
    }

    global class ResponsePayload {
        global String status;
        global String detail;
        global ResponsePayload(String status, String detail) {
            this.status = status; this.detail = detail;
        }
    }
}

Idempotency is the caller's requirement and your implementation duty: upsert on an External Id with the Unique attribute, or record the caller's Idempotency-Key and short-circuit duplicates.

6. Data virtualisation

Salesforce Connect maps external tables to external objects. Relationships to external parents use external lookup (matches the External ID standard field); external children to Salesforce parents use indirect lookup (matches a custom field flagged External ID + Unique). The 120-second callout timeout and adapter rate limits apply to every page render.

Anti-patterns

Anti-patternFailing codeFix
Callout after DMLinsert acct; Http h = new Http(); h.send(req); -> You have uncommitted work pendingDo the callout first, or enqueue Queueable implements Database.AllowsCallouts after the DML
Callout in a triggertrigger T on Account (after insert) { BillingGateway.createAccount(...); }Publish a platform event or enqueue a Queueable from the trigger handler
Secret in code or custom settingreq.setHeader('Authorization', 'Bearer ' + SECRET_C__c)External Credential principal parameter; reference {!$Credential....} or let generateAuthorizationHeader do it
No timeoutdefault 10 s per callout, 120 s cumulative per transactionreq.setTimeout(n) sized to the endpoint SLA; sum of all callouts must stay under 120 s
Unbounded retry loopQueueable re-enqueuing itself on every failureBounded attempts + exponential backoff + dead letter (references/callout-and-retry.md)
Row-by-row REST calls from middleware5,000 single-record POSTssObject Collections (200 records/request) or Bulk API 2.0 for >2,000
Trusting HTTP 200 from compositeif (res.getStatusCode() == 200) success = true;Composite returns 200 even when a subrequest failed; inspect each subrequest result body
Mock that ignores the endpointsingle-response HttpCalloutMock for a multi-endpoint flowRouter mock keyed on endpoint + method (references/integration-testing.md)
Using retired syntax or APIssfdx force:apex:execute, SOAP login(), Outbound Messaging, CometD for new worksf apex run, OAuth 2.0, Flow + platform events, Pub/Sub API

Verification

Local gate (no org needed) - static analysis and Jest:

node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" local --changed

Apex tests including callout mocks, in an org:

node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" apex \
  --target-org vf-dev --tests BillingGatewayTest,OrderSyncResourceTest

Probe the deployed integration surface against a real org (read-only):

# Named credential and external credential exist and are retrievable
sf project retrieve start --metadata NamedCredential:Billing_API --target-org vf-int

# Apex REST endpoint answers (uses the CLI's authenticated session)
sf api request rest "/services/apexrest/order-sync/?externalId=EXT-1" \
  --method GET --include --target-org vf-int

# Org limits that integrations consume
sf org list limits --target-org vf-int --json

# Platform event publishing worked (event bus usage is visible in limits, deliveries in the log)
sf data query --query "SELECT COUNT(Id) FROM AsyncApexJob WHERE JobType = 'Queueable' AND Status = 'Failed' AND CreatedDate = TODAY" \
  --target-org vf-int

Post-deploy smoke (wave 4, runs the anonymous-Apex probes and writes .vibeforce/reports/smoke-<ISO>.json):

node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" smoke --target-org vf-int

Never point a callout smoke probe at a production third-party endpoint. Sandbox endpoints only, or mock through middleware - see skill sf-post-deploy-verification.

References

  • references/pattern-catalogue.md - the six patterns with when/mechanism/limits/example, middleware capability matrix, legacy replacement table.
  • references/named-credentials.md - NamedCredential and ExternalCredential metadata, authentication protocol matrix, permission set wiring, callout: usage, JWT claims, troubleshooting.
  • references/inbound-apis.md - REST/composite/composite-graph/ collections/Bulk API 2.0/Apex REST/GraphQL/Pub-Sub with curl and sf api request rest, limits.
  • references/callout-and-retry.md - Queueable + Finalizer retry engine, exponential backoff, dead letters, idempotency, transaction rules, callout limits.
  • references/integration-testing.md - HttpCalloutMock, Test.setMock, multi-endpoint router mock, Apex REST tests, event tests, contract tests.

Official documentation used:

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.