Skip to content

Repository files navigation

Salesforce Integration Patterns

Production-style integration patterns on one small domain: Named Credential callouts, a Queueable with exponential-backoff retry and a Finalizer, Platform Events, a Change Data Capture handler, an inbound REST resource, and audit logging. It comes with a dependency-free Node mock server for local work.

Disclaimer: Representative portfolio project built independently with synthetic data. It is not code from any employer or client.

The "Customer Profile API" is fictional. Its endpoint (customer-profile.example.com) is a reserved example domain.

Business use case

When a customer Account is created or changed in Salesforce, a downstream customer-profile system needs a copy, and it calls back with its own identifier. The integration has to:

  • never hold credentials in code or metadata;
  • survive downstream outages without losing updates or flooding the API;
  • avoid sync loops, because writing the result back to the Account is itself a change;
  • validate everything that comes in;
  • leave an audit trail an operations team can search by correlation id, without storing personal data.

Architecture

sequenceDiagram
    autonumber
    participant U as User / API client
    participant SF as Account (Salesforce)
    participant CDC as AccountChangeEventTrigger<br/>(Change Data Capture)
    participant PE as Account_Sync__e<br/>(Platform Event)
    participant SUB as AccountSyncEventTrigger
    participant Q as AccountSyncQueueable
    participant F as AccountSyncFinalizer
    participant API as Customer Profile API<br/>(callout:Customer_Profile_API)
    participant LOG as Integration_Log__c

    U->>SF: create / update Account
    SF-->>CDC: AccountChangeEvent
    CDC->>CDC: ignore sync-only field changes (loop guard)
    CDC->>PE: EventBus.publish (PublishAfterCommit)
    PE-->>SUB: deliver batch
    SUB->>Q: enqueue once, de-duplicated Ids
    Q->>API: POST /v1/customers/sync (max 50 per call)
    API-->>Q: 200 / 4xx / 5xx / timeout
    Q->>SF: update Sync_Status__c, External_Customer_Id__c (USER_MODE)
    Q->>LOG: masked request/response, attempt, duration
    Q-->>F: job ends (success or uncaught error)
    alt retryable and attempts left
        F->>Q: re-enqueue attempt+1 after 1, 2, 4, 8 min
    else more Ids remaining
        F->>Q: enqueue next chunk (attempt 1)
    else exhausted or permanent error
        F->>SF: Sync_Status__c = Failed
    end
    API->>SF: POST /services/apexrest/customer-sync/v1/ (callback)
    SF->>LOG: inbound request logged
Loading
Pattern Implementation
Secure outbound callout CustomerProfileClient uses callout:Customer_Profile_API/.... The Customer_Profile_API Named Credential points to an External Credential (Custom protocol, Authorization: Bearer {ApiKey} header formula). The key is entered in Setup after deployment, never committed.
Retry with exponential backoff RetryPolicy: retries 408/425/429/5xx and transport errors (CalloutException). Attempt cap 4, delays 1, 2, 4, 8 minutes (capped at 10), using System.enqueueJob(job, delayMinutes). 4xx errors fail at once.
Finalizer AccountSyncFinalizer runs in its own transaction after every job, including uncatchable failures such as limit exceptions. It logs the failure, decides retry or chain or stop, and marks Accounts Failed when attempts run out.
Chunking One callout per job, up to 50 Accounts. The Finalizer chains the remaining Ids.
Platform Event Account_Sync__e (High Volume, Publish After Commit). AccountSyncPublisher logs publish failures instead of throwing. AccountSyncEventSubscriber de-duplicates Ids and enqueues a single job per delivered batch.
CDC handler AccountChangeEventHandler handles CREATE, UPDATE, UNDELETE and the GAP_* variants. It ignores updates that only touch sync bookkeeping fields, to prevent an infinite loop.
Inbound REST CustomerSyncRestResource (/customer-sync/v1/*). POST takes a callback, GET returns status by external id. Strict JSON contract (deserializeStrict), 32 KB size limit, Id type checks, format regexes, and 400/404/413/422 responses with error lists.
Logging IntegrationLogger writes Integration_Log__c rows with direction, status, endpoint (the Named Credential path, never a resolved URL), status code, attempt, duration and correlation id. Bodies are masked (email, phone, card, SSN, tokens, ...) and truncated.
Test seam AsyncJobEnqueuer wraps System.enqueueJob, so retry and chaining decisions can be asserted in tests, where chaining is restricted.

Tech stack

Apex (API 62.0), Named Credentials and External Credentials, Platform Events, Change Data Capture, Queueable + Finalizer, Apex REST, HttpCalloutMock, Node.js 20 (node:http, node:test), Docker, Prettier, Salesforce Code Analyzer v5, GitHub Actions.

Project structure

salesforce-integration-patterns/
├── .github/workflows/ci.yml
├── config/project-scratch-def.json
├── data/                                   # sf data import tree plan (synthetic Accounts)
├── force-app/main/default/
│   ├── classes/                            # client, retry, queueable, finalizer, events, CDC, REST, logger + tests
│   ├── externalCredentials/                # placeholder, NO secrets
│   ├── namedCredentials/
│   ├── objects/
│   │   ├── Account/fields/                 # External_Customer_Id__c, Sync_Status__c, Last_Synced_At__c
│   │   ├── Account_Sync__e/                # platform event
│   │   └── Integration_Log__c/             # audit log object
│   ├── permissionsets/                     # Integration_Sync, Integration_Log_Viewer
│   ├── platformEventChannelMembers/        # enables CDC for Account
│   └── triggers/                           # AccountChangeEventTrigger, AccountSyncEventTrigger
├── mock-server/
│   ├── src/app.js, src/server.js
│   ├── test/app.test.js
│   ├── Dockerfile, .dockerignore
│   └── package.json
├── scripts/apex/request-sync.apex
├── .env.example
├── code-analyzer.yml
└── package.json

Setup (Salesforce)

sf org login web --set-default-dev-hub --alias devhub
sf org create scratch --definition-file config/project-scratch-def.json --alias integ --set-default --duration-days 7
sf project deploy start --target-org integ
sf org assign permset --name Integration_Sync --target-org integ
sf org assign permset --name Integration_Log_Viewer --target-org integ
sf data import tree --plan data/sample-data-plan.json --target-org integ
sf apex run test --target-org integ --test-level RunLocalTests --code-coverage --result-format human --wait 30

After deployment:

  1. Credential: Setup > Named Credentials > External Credentials > Customer Profile API > Principals > Default. Add an authentication parameter ApiKey. For the mock server, any non-empty value works.
  2. Endpoint: to reach a real service (or a tunnel to the mock server), change the URL on the Customer Profile API Named Credential. Never point it at a production system from a demo org.
  3. Subscriber user (recommended): create a PlatformEventSubscriberConfig so AccountSyncEventTrigger runs as a dedicated integration user who has Integration_Sync, instead of the Automated Process user.
  4. Try it: sf apex run --file scripts/apex/request-sync.apex --target-org integ, then look at the Integration Log records.

The External Credential XML is a placeholder written by hand. If your org's metadata version expects a different parameter layout, create the External Credential in Setup and retrieve it with sf project retrieve start --metadata ExternalCredential:Customer_Profile_API.

Mock server (local)

Dependency-free (node:http only) mock of the Customer Profile API:

cd mock-server
npm ci
npm test                 # node:test suite
npm start                # http://localhost:3000
curl -s localhost:3000/health
curl -s -X POST localhost:3000/v1/customers/sync \
  -H 'Authorization: Bearer local-test-only' -H 'Content-Type: application/json' \
  -d '{"customers":[{"salesforceId":"001000000000001AAA","name":"Synthetic Customer"}]}'
Route Behaviour
GET /health 200 {"status":"ok"} (no auth)
POST /v1/customers/sync Validates the payload (at most 200 customers, Id format, JSON content type, 1 MB limit). Returns a deterministic CUST-###### id per customer. Names containing REJECT are rejected, to exercise partial failure.
GET /v1/customers/:externalId Synthetic customer. CUST-000000 returns 404, a malformed id returns 400
Header X-Mock-Scenario server-error (503), rate-limit (429 + Retry-After), bad-request (400), unauthorized (401), timeout (delayed past the 10 s Apex timeout)

Docker:

cd mock-server
docker build -t customer-profile-mock .
docker run --rm -p 3000:3000 customer-profile-mock

The image runs as the non-root node user, includes a HEALTHCHECK, and contains no secrets. The bearer check accepts any non-empty token because it is a mock.

Synthetic sample data

data/Accounts.json has three fictional Accounts with Sync_Status__c = Pending. One is named "Synthetic REJECT Example" so that the mock server returns a rejection.

Configuration template

.env.example (mock server):

PORT=3000
HOST=0.0.0.0

Tunables in Apex:

Constant Class Default
MAX_ATTEMPTS RetryPolicy 4
MAX_DELAY_MINUTES RetryPolicy 10
BATCH_SIZE AccountSyncQueueable 50
TIMEOUT_MS CustomerProfileClient 10000
MAX_BODY_BYTES CustomerSyncRestResource 32768

Security considerations

  • No secrets anywhere. Credentials live only in the org's External Credential principal. Code refers only to callout:Customer_Profile_API. CI uses a SFDX_AUTH_URL secret that is referenced, never stored.
  • Least privilege. Integration_Sync grants only the sync fields, the REST class and the credential principal. Integration_Log_Viewer is read-only.
  • USER_MODE for business reads and writes. Logs are written in SYSTEM_MODE with allOrNone=false, so auditing never blocks or rolls back business work.
  • PII masking before any request or response body is stored. Only identifiers and business fields are sent, from an explicit allow-list (AccountSyncService.buildPayload).
  • Inbound hardening. Strict deserialisation, size cap, regex and Id-type validation, and generic error messages with no stack traces. The correlation id is echoed for tracing.
  • Loop prevention in the CDC handler, and de-duplication in the subscriber.

Testing approach

HttpCalloutMock-based Apex tests:

Test class Scenarios
CustomerProfileClient_Test 200 success (endpoint, method, headers, no auth header in code), 400, 422 (via service), 429, 503, timeout (CalloutException), URL encoding
AccountSyncQueueable_Test End-to-end success with write-back, 503 leading to RETRY, last-attempt failure, 4xx permanent failure, timeout leading to RETRY, unparseable 200 body, partial accept/reject, batch size of 50 per execution, payload allow-list
AccountSyncFinalizer_Test Hand-written FinalizerContext: retry with backoff, chaining the remaining Ids, stop, unhandled exception (retried and logged), exhaustion (Accounts marked Failed)
AccountSyncEvents_Test Subscriber de-duplication, invalid Ids, real event bus delivery (Test.getEventBus().deliver()) enqueuing the job
AccountChangeEventHandler_Test Field filters, change types, Test.enableChangeDataCapture() end to end
CustomerSyncRestResource_Test Accepted and rejected callbacks, blank, malformed and unknown-attribute bodies, field validation, 413, 404, GET found / not found / bad format
RetryPolicy_Test, IntegrationLogger_Test Backoff maths, status classification, masking, truncation, persistence

Mock server: 20 node:test tests (routes, validation, auth, every fault scenario, the timeout, helper functions).

Apex tests need an org and run in the optional CI scratch-org job. The mock-server tests run anywhere with Node 20 or later.

Continuous integration

  1. static-checks: npm ci, Prettier check (Apex, XML, mock-server JS), mock-server npm ci && npm test, and Salesforce Code Analyzer (fails on High or Critical findings).
  2. scratch-org-validate (optional): runs only if the SFDX_AUTH_URL secret exists. It deploys to a fresh scratch org, runs Apex tests, then deletes the org.

Limitations and future enhancements

  • No jitter on the backoff. With many concurrent jobs, adding random jitter would spread retries out.
  • The CDC handler is a stub: it publishes events but does not use commitNumber or transactionKey for ordering or de-duplication across transactions.
  • One callout per job keeps the Finalizer logic simple. A Continuation or composite endpoint could raise throughput.
  • The External Credential uses the Custom protocol with an API-key header. A real deployment would more likely use OAuth 2.0 client credentials or JWT bearer.
  • Possible additions: MuleSoft or API-gateway variants, a dead-letter object for exhausted syncs, a replay tool, Pub/Sub API subscriber examples, and OpenAPI docs for the mock.

License

MIT. Copyright (c) 2026 Vanaja Kumari.

About

Salesforce integration patterns: Named/External Credential callouts, Queueable with exponential backoff and Finalizer, Platform Events, Change Data Capture, validated Apex REST endpoint and masked integration logging. Includes a tested Node mock server with Dockerfile. Synthetic data only.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages