SfSkills Domains Docs GitHub

Home / apex / change-data-capture-apex

change-data-capture-apex

Use this skill when writing or reviewing Apex change event triggers: trigger syntax on ChangeEvent objects, reading ChangeEventHeader fields (changeType, changedFields, recordIds, commitUser), handling CREATE/UPDATE/DELETE/UNDELETE and GAP events in Apex, and configuring entity tracking.

Open the skill folder on GitHub


Change Data Capture Apex

Use this skill when implementing or reviewing Apex logic that subscribes to Salesforce record change events via CDC triggers. CDC triggers fire asynchronously after DML commits and provide rich header metadata (changeType, changedFields, recordIds, commitUser) for precise, efficient processing. This skill covers the complete Apex-side CDC pattern: trigger declaration, header field access, change-type routing, gap event handling, field-level filtering, and entity configuration.


Before Starting

Gather this context before working on anything in this domain:


Questions to Ask Before Configuring

Ask these before writing the trigger. Each one changes a branch in the handler or a line in the metadata, and each maps to a gotcha in references/gotchas.md.

AskWhy it mattersWhat a good answer adds
"What is the worst thing that happens if this event is delivered twice?"Decides whether an idempotency store is required. Nothing in Apex deduplicates for you, and transactionKey alone is not a unique key — a lead conversion emits four numbered events under one key (apexrefguide L157429–157436)A dedupe key of transactionKey + sequenceNumber and a unique External Id field to hold it, instead of a handler that double-posts on redelivery (Artifact 5 in references/code-examples.md)
"Which specific field changing is the reason this subscriber exists?"Decides the changedFields test. changedFields always contains LastModifiedDate on an update (apexrefguide L157231–157232), so "did anything change" is never a useful filterA named field the handler asks for, plus the nulledFields companion check so clearing the field still counts (apexrefguide L157381–157385)
"Is that field a formula, a roll-up, or a stored field?"Change events exclude fields "whose value isn't on the record and is derived from another record or from a formula… except roll-up summary fields, which are included" (object_reference L5158–5163)Either a stored field to watch, or the discovery — before build, not after — that a formula field will never appear in changedFields at all
"When the platform cannot generate a full event, what should happen?"Decides the GAP_* / GAP_OVERFLOW branch (apexrefguide L157298–157304). A subscriber with no gap branch drifts silentlyA named resync action and something that counts it, rather than a changeType chain that falls through to nothing
"Can the downstream effect of this handler itself change the same object?"Decides whether changeOrigin must be read. It is "only populated for changes done by API apps or from Lightning Experience" and exists to "detect whether your app initiated the change to not process the change again and potentially avoid a deep cycle of changes" (apexrefguide L157197–157200)A loop-breaker chosen deliberately — changeOrigin, a control flag, or apex/recursive-trigger-prevention — instead of a cycle discovered by a runaway event count
"Who owns the records this handler creates, and does it ever send email?"The subscriber runs as the Automated Process entity by default, which cannot send email, and Automated Process users "can't perform Object and FLS checks in custom code unless appropriate permission sets are explicitly applied" (api_meta L96453–96465, apexdev L11921–11922)A PlatformEventSubscriberConfig with a real user, or a written decision that Automated Process is correct plus the permission sets it needs
"How many events will one invocation actually carry, and what will they weigh?"The batch is 2,000, not 200 (apexdev L19862), against 100 SOQL / 150 DML / 6 MB heap (apexdev L19542, L19553, L19577)A deliberate batchSize — never 1 (api_meta L96419–96424) — and a handler that hydrates only the fields it consumes

What a proper configuration adds over just writing the trigger: CDC enablement ships as metadata instead of a Setup click that nobody repeats in the next org, redelivery cannot duplicate downstream work, a gap becomes a counted resync rather than silent drift, the subscriber's identity and permissions are a decision rather than a default, and the batch size is sized against heap rather than inherited at 2,000.


Core Concepts

CDC Trigger Syntax and Execution Model

A CDC trigger is always an after insert trigger on the change event object — not on the base sObject. The change event type name follows the pattern <ObjectApiName>ChangeEvent for standard objects and <ObjectApiName>__ChangeEvent for custom objects.

trigger AccountChangeEventTrigger on AccountChangeEvent (after insert) {
    // Trigger.new contains up to 2,000 AccountChangeEvent records per batch
}

CDC triggers:

ChangeEventHeader and Header Fields

Every event in Trigger.new exposes a ChangeEventHeader field (type EventBus.ChangeEventHeader) that carries all change metadata. Access it via the event's .ChangeEventHeader property.

Key header fields for Apex:

Every property below is from EventBus.ChangeEventHeader (apexrefguide L157183–157447). There are twelve; a subscriber that reads only the first four is leaving its idempotency and its failure modes on the table.

FieldTypeDescriptionLine
changeTypeStringCREATE, UPDATE, DELETE, UNDELETE, SNAPSHOT (reserved for future use), GAP_CREATE/GAP_UPDATE/GAP_DELETE/GAP_UNDELETE, and GAP_OVERFLOW for overflow eventsL157286–157304
changedFieldsList\<String\>Fields changed in an update, including the LastModifiedDate system field; empty for other operations including record creation. API 47.0+L157231–157236
nulledFieldsList\<String\>Fields whose values were changed to null in an update. This is how you tell "set to null" apart from "unchanged"L157381–157385
diffFieldsList\<String\>Fields whose values are sent as a unified diff because they contain large text valuesL157353–157360
recordIdsList\<String\>One or more record IDs. Salesforce merges notifications when the same change hits multiple records of one object type within one second in one transaction. Can also be a wildcard (001*) for a custom field type conversion that loses dataL157393–157417
entityNameStringAPI name of the changed object — Account, MyObject__cL157371–157377
changeOriginStringcom/salesforce/api/<API_Name>/<API_Version>;client=<Client_ID>. Only populated for changes made by API apps or from Lightning Experience. Exists so an app can detect its own changes "to not process the change again and potentially avoid a deep cycle of changes"L157245–157275
commitUserStringID of the user that ran the change operationL157333–157339
commitTimestampLongMilliseconds since 1970-01-01 00:00:00 GMT. A Long, not a DatetimeL157321–157330
commitNumberLongSystem change number, sequential — but "not guaranteed to be unique in Salesforce… unique only in a single database instance" and possibly non-sequential after an instance migration. Diagnostics onlyL157306–157318
sequenceNumberIntegerThe change's sequence within the transaction, starting at 1. A lead conversion produces fourL157420–157436
transactionKeyStringUniquely identifies each Salesforce transaction; groups all changes made in the same transactionL157438–157447

The idempotency key is transactionKey + sequenceNumber, not transactionKey alone and never commitNumber. See references/code-examples.md Artifact 4.

UNVERIFIED (2026-09-05): the claim that changedFields and nulledFields are available in Apex triggers and Pub/Sub API only, and absent in CometD subscribers, is a Change Data Capture Developer Guide claim; the Apex Reference Guide documents the properties without stating which subscriber transports carry them.

UNVERIFIED (2026-09-05): recordIds is documented as a property — public List<String> recordids {get; set;} (apexrefguide L157400) — not as a getRecordIds() method. The getter form used elsewhere in this package comes from the Change Data Capture Developer Guide; the only getter the Apex Developer Guide shows is getChangeType(), in the External Change Data Capture sample at apexdev L29639. New code in references/code-examples.md uses the property form.

Change Event Body Fields in Apex

Unchanged fields are null in the event message. That is the whole reason nulledFields exists — its documented purpose is "to determine if a field was changed to null in an update and isn't an unchanged field" (apexrefguide L157381–157385). Do not read a field off the event and assume its value is current; use changedFields to decide what changed, then query the record for full state.

The event object is also not a full copy of the record. Change event fields correspond to the parent object's fields with these exclusions (object_reference L5157–5163):

So a formula field can never appear in changedFields, and a subscriber designed to watch one will never fire. Watch the stored fields the formula reads instead.

Change events also exist for only a subset of standard objects, plus all custom objects; the list is in object_reference L5211+. Change events for custom settings "aren't supported in Apex triggers but are supported in other types of subscribers" (object_reference L5140–5143).

Entity Tracking and GAP Events

CDC must be explicitly enabled per object. The Setup page is one face of it: "the default standard channel corresponds to the entity selection in the Change Data Capture page in Setup" (api_meta L95842–95844). The other face is deployable — a PlatformEventChannelMember naming AccountChangeEvent on the ChangeEvents channel (api_meta L96098–96108). Ship the metadata; do not leave a Setup click as an undocumented prerequisite in every downstream org. references/code-examples.md Artifact 1 is the file.

UNVERIFIED (2026-09-05): the 5-entity default and the CDC add-on that lifts it are Change Data Capture allocation numbers. The App Limits cheat sheet in the grounding corpus only points at the "Change Data Capture Allocations" section (L1249–1251) without reproducing the values.

Gap events carry a changeType prefixed by GAP_ — GAP_CREATE, GAP_UPDATE, GAP_DELETE, GAP_UNDELETE — and overflow events use GAP_OVERFLOW (apexrefguide L157298–157304). A changeType.startsWith('GAP_') test catches all five.

UNVERIFIED (2026-09-05): the causes of gap events (event over 1 MB, a bulk operation bypassing the application server, an internal error) and the claim that gap events carry recordIds but no field values are Change Data Capture Developer Guide statements. The Apex Reference Guide names the changeType values without describing the payload.

UNVERIFIED (2026-09-05): the ALL_CHANGE_EVENTS channel, the AllChangeEvents event type and the ReceiveAllChangeEvents permission return zero hits across api_meta, object_reference, apexdev, apexrefguide and the App Limits cheat sheet. Treat as unconfirmed until read in the CDC guide.

Note: CDC triggers cannot subscribe to multi-entity channels. An Apex trigger is always bound to a single change event type (e.g., AccountChangeEvent). There is no equivalent of subscribing to /data/ChangeEvents in Apex.


Common Patterns

Pattern 1: Field-Selective UPDATE Processing

When to use: The trigger should only react when specific fields change (e.g., Status__c, OwnerId) rather than on any update.

How it works:

trigger CaseChangeEventTrigger on CaseChangeEvent (after insert) {
    List<Id> casesWithStatusChange = new List<Id>();

    for (CaseChangeEvent event : Trigger.new) {
        EventBus.ChangeEventHeader header = event.ChangeEventHeader;

        // Skip non-UPDATE events
        if (header.changeType != 'UPDATE') {
            continue;
        }

        // Only act when Status field is in changedFields
        if (header.changedFields.contains('Status')) {
            casesWithStatusChange.addAll((List<Id>) header.getRecordIds());
        }
    }

    if (!casesWithStatusChange.isEmpty()) {
        // Query for full current state — do not rely on event field values for UPDATE
        List<Case> cases = [SELECT Id, Status, OwnerId FROM Case WHERE Id IN :casesWithStatusChange];
        CaseStatusHandler.process(cases);
    }
}

Why not just read the field from the event: For UPDATE events, unchanged fields are null in the event body. Reading event.Status when Status did not change returns null, not the current value. Always use changedFields to filter, then query the record for current state.

Pattern 2: Change-Type Routing with GAP Handling

When to use: The trigger must handle all four operation types (CREATE, UPDATE, DELETE, UNDELETE) with distinct logic per type, and must be resilient to gap events.

How it works:

trigger AccountChangeEventTrigger on AccountChangeEvent (after insert) {
    List<Id> createdIds    = new List<Id>();
    List<Id> updatedIds    = new List<Id>();
    List<Id> deletedIds    = new List<Id>();
    List<Id> undeletedIds  = new List<Id>();
    List<Id> gapIds        = new List<Id>();

    for (AccountChangeEvent event : Trigger.new) {
        EventBus.ChangeEventHeader header = event.ChangeEventHeader;
        List<String> ids = header.getRecordIds();

        if (header.changeType == 'CREATE') {
            createdIds.addAll((List<Id>) ids);
        } else if (header.changeType == 'UPDATE') {
            updatedIds.addAll((List<Id>) ids);
        } else if (header.changeType == 'DELETE') {
            deletedIds.addAll((List<Id>) ids);
        } else if (header.changeType == 'UNDELETE') {
            undeletedIds.addAll((List<Id>) ids);
        } else if (header.changeType.startsWith('GAP_')) {
            // Gap event: no field values available — mark dirty for re-sync
            gapIds.addAll((List<Id>) ids);
        }
    }

    // Dispatch to handlers
    if (!createdIds.isEmpty())   AccountSyncHandler.handleCreate(createdIds);
    if (!updatedIds.isEmpty())   AccountSyncHandler.handleUpdate(updatedIds);
    if (!deletedIds.isEmpty())   AccountSyncHandler.handleDelete(deletedIds);
    if (!undeletedIds.isEmpty()) AccountSyncHandler.handleUndelete(undeletedIds);
    if (!gapIds.isEmpty())       AccountSyncHandler.handleGap(gapIds);
}

Why not just check for UPDATE: Failing to handle GAP events means silent data drift when events cannot be generated at full fidelity. Always check changeType.startsWith('GAP_') as a catch-all.

Pattern 3: Multi-Record Batch Processing with getRecordIds

When to use: A single change event can contain multiple record IDs when Salesforce merges identical changes (e.g., a bulk update of the same field on many records). Always iterate getRecordIds() rather than assuming a 1:1 event-to-record relationship.

How it works:

trigger ContactChangeEventTrigger on ContactChangeEvent (after insert) {
    Set<Id> affectedContacts = new Set<Id>();

    for (ContactChangeEvent event : Trigger.new) {
        EventBus.ChangeEventHeader header = event.ChangeEventHeader;
        // getRecordIds() returns List<String>; cast to Id after collecting
        affectedContacts.addAll((List<Id>) header.getRecordIds());
    }

    // Single SOQL for the entire batch
    if (!affectedContacts.isEmpty()) {
        List<Contact> contacts = [
            SELECT Id, AccountId, Email, Status__c
            FROM Contact
            WHERE Id IN :affectedContacts
        ];
        ContactIntegrationHandler.process(contacts);
    }
}

Why not iterate Trigger.new directly: Each Trigger.new record is an event, but one event can represent changes to multiple records. Iterating events without collecting all recordIds will process the batch incorrectly. Pattern: collect all IDs across the batch, then issue a single SOQL.


Decision Guidance

SituationRecommended ApproachReason
Subscribe to record changes inside Salesforce with Apex logicCDC trigger on <Object>ChangeEventNative async subscriber; full header field access
Need to act only when specific fields changeCheck header.changedFields before processingCDC exposes field-level delta; avoid unnecessary SOQL
Need to fire a synchronous callout from CDC handlerDispatch to @future(callout=true) or QueueableCDC triggers cannot perform synchronous callouts
Must handle DELETEAdd changeType == 'DELETE' branch; do not query deleted recordDeleted records are unavailable via SOQL without ALL ROWS
Event is manually published (not system DML)Use apex/platform-events-apex insteadCDC events are system-generated only; cannot be published manually
External system (MuleSoft, Kafka) needs CDC eventsUse integration/change-data-capture-integrationCometD/Pub/Sub API is the correct path for external subscribers
Enable CDC for an object in a repeatable wayDeploy PlatformEventChannelMember on the ChangeEvents channelThe Setup page selection is this metadata (api_meta L95842–95844, L96098–96108)
Subscriber needs a field that did not change on this eventCustom channel + enrichedFields on the member"A non-empty enriched field is added to an update or delete change event even when not changed" (api_meta L96047–96050)
Subscriber should never see most of the eventsfilterExpression on the channel member (API 56.0+)Filtering in the platform costs no Apex limits; filtering in the trigger costs a full 2,000-event batch (api_meta L96057–96066)
Redelivery must not double-apply the downstream effectDedupe on transactionKey + sequenceNumberOne transaction emits several numbered events — a lead conversion emits four (apexrefguide L157429–157436)
Need to order or key on a monotonic numberDo not use commitNumber"Not guaranteed to be unique in Salesforce — it is unique only in a single database instance" (apexrefguide L157306–157310)
Want to react to a formula or roll-up field changingWatch the stored fields the formula reads; roll-ups are fineFormula and derived fields are excluded from change events; roll-up summary fields are included (object_reference L5157–5163)
Handler's own DML could re-trigger this subscriberRead changeOrigin, or apply apex/recursive-trigger-preventionchangeOrigin exists to "detect whether your app initiated the change… avoid a deep cycle of changes" (apexrefguide L157197–157200)
Need to track > 5 objectsPurchase CDC add-on or reassess scopeUNVERIFIED (2026-09-05): the 5-entity default and the add-on are CDC allocation numbers not reproduced in the App Limits cheat sheet (L1249–1251)
Debugging a CDC trigger — no logs visibleSet trace flag on Automated Process entityCDC trigger runs under Automated Process, not the current user
Need to change the running user or batch sizeDeploy PlatformEventSubscriberConfig via Metadata APIControls trigger user context and invocation batch size

  1. Answer the seven questions above first. The idempotency answer decides whether Artifact 5

    (Change_Event_Receipt__c) ships; the watched-field answer decides whether that field is even capable of appearing in changedFields (formula fields are not — object_reference L5157–5163). Both are cheaper to settle now than after the handler exists.

  2. Confirm the change event name and check it against the siblings. <Object>ChangeEvent for

    standard objects, <Object>__ChangeEvent for custom (object_reference L5148–5156). If the subscriber is external, stop here and use integration/change-data-capture-integration. If the event is published by your own code rather than by a DML commit, use apex/platform-events-apex.

  3. Ship CDC enablement as metadata, not a Setup click. Write

    ChangeEvents_<Object>ChangeEvent.platformEventChannelMember-meta.xml (references/code-examples.md Artifact 1). Use a custom channel with enrichedFields or a filterExpression (Artifact 2) only when the subscriber needs a field that did not change, or needs the platform to drop events before Apex sees them.

  4. Write a one-line trigger and put everything in the handler — Artifacts 3 and 4. The handler

    makes four passes: decode headers with no SOQL or DML; query the receipt store once to drop units already processed; issue one hydration query; insert receipts with allOrNone = false. Route on changeType with a real else branch, guard recordIds against the 001* wildcard before casting to Id, and log through templates/apex/ApplicationLogger.cls.

  5. Decide the subscriber's identity before deploying, not after a permissions failure. Ship

    PlatformEventSubscriberConfig (Artifact 6) with a real user if the handler creates owned records or sends email; otherwise grant the Automated Process user the object and field permissions the handler needs (apexdev L11921–11922).

  6. Run the checker over the source tree. Its seven rules map one-to-one to gotchas in

    references/gotchas.md: python3 skills/apex/change-data-capture-apex/scripts/check_change_data_capture_apex.py --manifest-dir force-app/main/default Add --strict in CI to promote WARN findings to failures.

  7. Test with Test.enableChangeDataCapture() and an explicit deliver() — Artifact 7. Call

    enableChangeDataCapture() at the top, before any DML (apexrefguide L240421–240423). Drain the CREATE event before asserting on an UPDATE. Remember that the method fires triggers "regardless of the entities selected in Setup" (apexrefguide L240433–240435), so a green test is not evidence the entity is enabled — verify that separately (references/code-examples.md, Verification).


Review Checklist


Salesforce-Specific Gotchas

  1. CDC triggers run under Automated Process — not the current user — Debug logs created by the trigger execution are attributed to the Automated Process entity, not the DML-performing user. Logs will not appear in the Developer Console log tab unless a trace flag is set for Automated Process in Setup > Debug Logs. Developers who look in the wrong place will conclude the trigger is not firing when it is.
  2. Unchanged fields are null in UPDATE event body — For an UPDATE change event in Apex, fields that were not changed in the triggering DML are null on the event object. Reading event.BillingCity when BillingCity was not updated returns null — it does not return the current field value. Only changedFields reliably identifies what changed. Always query the record for full state rather than reading fields directly from UPDATE event bodies.
  3. Apex CDC triggers cannot subscribe to multi-entity channels — An Apex trigger is bound to exactly one change event type. There is no way to write a single Apex trigger that handles AccountChangeEvent and ContactChangeEvent. Unlike external subscribers that can subscribe to /data/ChangeEvents, Apex triggers require one trigger file per tracked entity. Architects expecting a "catch-all" Apex subscriber will need one trigger per object.
  4. GAP events carry no field values — A gap event fires when the full change event cannot be generated (event too large, bulk bypass, internal error). The event body is empty; only recordIds and the GAP-prefixed changeType are available. Apex code that skips the GAP check and falls through to field access will read null fields and silently skip the change. Production CDC subscribers must include explicit GAP handling that queues a record re-fetch.
  5. CDC triggers are subject to synchronous governor limits — Despite running asynchronously, CDC triggers consume Apex synchronous limits (100 SOQL, 150 DML operations, 6 MB heap) per batch invocation. Read that heap number carefully: it is the synchronous 6 MB, not the 12 MB an asynchronous context would suggest — the "runs asynchronously" framing is exactly what misleads people into budgeting double. A batch of 2,000 events that naively issues one SOQL per event will hit the 100-SOQL limit; 2,000 events plus their queried parent records against 6 MB is a real heap constraint, not a theoretical one. Always collect IDs across the full Trigger.new loop and issue bulk SOQL outside the loop, and keep only the fields you need.

Output Artifacts

ArtifactDescription
CDC Apex triggerCorrectly declared after insert trigger on the change event type with header access and change-type routing
changedFields filter logicField-selective UPDATE filter using header.changedFields.contains()
GAP event handlerBranch detecting GAP_-prefixed changeType and dispatching to re-fetch logic
Entity tracking metadataPlatformEventChannelMember XML that enables the object for CDC on the ChangeEvents channel, or a custom channel plus member with enrichedFields / filterExpression
Idempotency storeChange_Event_Receipt__c with a unique External Id keyed on transactionKey + sequenceNumber, and the partial-success insert that claims a unit
PlatformEventSubscriberConfigMetadata snippet to override running user or batch size when defaults are insufficient
Apex unit test patternTest class using Test.enableChangeDataCapture() plus explicit Test.getEventBus().deliver() calls, asserting the handler's effect for CREATE and UPDATE

Reference Files

FileRead it when
references/code-examples.mdYou are building the slice — channel-member XML, custom channel with enriched fields, trigger, idempotent handler, receipt object, subscriber config, test class, package.xml, deploy order, verification
references/gotchas.mdThe subscriber is behaving in a way the code does not explain: a field that never appears in changedFields, a cast that throws, a receipt that duplicates, logs that are nowhere
references/examples.mdYou want worked scenarios: SLA-field filtering, a full change-type router with gap recovery, and the raw event payload with the dedupe key derived from it
references/llm-anti-patterns.mdYou are reviewing generated CDC Apex — seven failure modes with detection hints, including the heap number assistants invent
references/well-architected.mdYou are tagging findings by pillar, or you need the exact source and line range behind a claim in this package

Package files