SfSkills Domains Docs GitHub

Home / apex / case-trigger-patterns

case-trigger-patterns

Use when writing Apex triggers on the Case object — specifically for invoking assignment rules programmatically, auto-associating entitlements in a trigger, handling merge trigger behavior on losing records, or understanding why milestone completion does not fire automatically when a case closes.

Open the skill folder on GitHub


Case Trigger Patterns

This skill activates when a practitioner needs Apex trigger logic specific to the Case object, covering four non-obvious platform behaviors: DML from Apex bypasses case assignment rules by default; entitlement auto-association requires an explicit Before Insert/Update query; merge operations fire delete triggers on losing records with a populated MasterRecordId; and closing a case does not automatically complete open milestones.


Before Starting

Gather this context before working on anything in this domain:


Core Concepts

Assignment Rule Bypass by Default

When Apex performs DML on Case records — insert, update, or upsert — Salesforce does not invoke case assignment rules. This is documented behavior: DML operations issued programmatically bypass assignment rule evaluation unless you explicitly opt in via Database.DmlOptions.

To invoke the active rule, create a Database.DmlOptions instance, set assignmentRuleHeader.useDefaultRule = true, and pass the options to the DML call. To invoke a specific rule by Id, set assignmentRuleHeader.assignmentRuleId to the rule's 18-character Id instead.

This differs from UI behavior: a user saving a Case via the Lightning record page can choose to trigger assignment rules. Apex does not replicate that behavior automatically.

Entitlement Auto-Association Pattern

When an Account-based entitlement process is in place, Salesforce can auto-associate an entitlement at case creation only if the org is configured to do so and the correct entitlement covers the account. However, when granular EntitlementContact junction records are used — where entitlement coverage is contact-specific, not account-wide — the platform cannot determine the correct entitlement automatically.

The trigger-based pattern for this situation is a Before Insert (and optionally Before Update) trigger that queries EntitlementContact for the junction records linking the case's contact to an active entitlement, then sets Case.EntitlementId before the record is saved. Doing this in a Before trigger avoids an extra update DML operation.

Merge Trigger Behavior and MasterRecordId

When two Case records are merged in Salesforce (via the UI or via Apex merge DML), the platform fires:

Inside a before delete or after delete trigger on Case, Trigger.new and Trigger.newMap are null. Use Trigger.old and Trigger.oldMap.

MasterRecordId is readable only in after delete. The Apex Developer Guide states: "The MasterRecordId field is only set in after delete trigger events." The documented order is: the before delete trigger fires, then the platform deletes the records, reparents children, and sets MasterRecordId, then the after delete trigger fires. A before delete handler that branches on MasterRecordId != null therefore never takes the merge branch — every merged record silently falls through to the true-delete path, which is exactly the data-loss scenario the branch exists to prevent. Detect merges in after delete only.

The guide also states: "Merge events do not fire their own trigger events. Instead, they fire delete and update events," and "Any child records that are reparented as a result of the merge operation do not fire triggers."

Milestone Completion Gap at Case Close

Entitlement Process milestones attached to a case do not automatically reach Completed status when the case's Status is set to a closed value. Each milestone has its own completion criteria defined in the entitlement process, but if those criteria are met by closing the case, the platform evaluates the entitlement process asynchronously. In practice, reporting on milestone completion in the same transaction as the case close will see milestones still open.

If business requirements demand that all open milestones are completed when a case closes, the correct approach is an After Update trigger (or Flow) that detects the case transitioning to a closed status, then queries CaseMilestone records for that case where IsCompleted = false and updates CaseMilestone.CompletionDate to the current datetime.


Common Patterns

Pattern 1: Invoking Case Assignment Rules from an Apex Trigger or Service

When to use: A trigger, Batch Apex job, or integration service inserts or updates Case records and the assignment rule must fire to route the case to the correct queue or user.

How it works: Set Database.DmlOptions.assignmentRuleHeader.useDefaultRule = true and pass the options to the DML call. This can be done either directly in the trigger (less common) or in the service class / handler that performs the DML.

// In a service class or trigger handler performing the insert
Database.DmlOptions opts = new Database.DmlOptions();
opts.assignmentRuleHeader.useDefaultRule = true;

List<Case> casesToInsert = new List<Case>{ /* ... */ };
Database.insert(casesToInsert, opts);

To use a specific rule Id:

Database.DmlOptions opts = new Database.DmlOptions();
opts.assignmentRuleHeader.assignmentRuleId = '01Q000000000001AAA'; // 18-char rule Id
Database.insert(casesToInsert, opts);

Note: Database.DmlOptions cannot be passed to the Apex insert DML keyword. Use Database.insert() to supply options.

Why not the alternative: Relying on the insert keyword (without DML options) silently skips assignment rules. Cases end up unassigned or assigned to the default owner. The failure is silent — no error, no warning, no log.


Pattern 2: Entitlement Auto-Association in a Before Insert Trigger

When to use: The org uses contact-specific entitlement coverage (EntitlementContact junction records) and the platform cannot determine the correct entitlement automatically at case creation.

How it works: In Before Insert, query EntitlementContact for active entitlements linked to the case's ContactId, then set Case.EntitlementId on the record before it is committed.

trigger CaseTrigger on Case (before insert, before update, after update, after delete) {
    if (Trigger.isBefore && Trigger.isInsert) {
        CaseTriggerHandler.associateEntitlements(Trigger.new);
    }
    // ... other context routing
}
public with sharing class CaseTriggerHandler {

    public static void associateEntitlements(List<Case> newCases) {
        // Collect ContactIds for cases that have no entitlement yet
        Set<Id> contactIds = new Set<Id>();
        for (Case c : newCases) {
            if (c.ContactId != null && c.EntitlementId == null) {
                contactIds.add(c.ContactId);
            }
        }
        if (contactIds.isEmpty()) return;

        // Query EntitlementContact for active entitlements linked to these contacts
        Map<Id, Id> contactToEntitlement = new Map<Id, Id>();
        for (EntitlementContact ec : [
            SELECT ContactId, EntitlementId
            FROM EntitlementContact
            WHERE ContactId IN :contactIds
              AND Entitlement.Status = 'Active'
              AND Entitlement.EndDate >= TODAY
            LIMIT 1000
        ]) {
            // Take the first active entitlement per contact
            if (!contactToEntitlement.containsKey(ec.ContactId)) {
                contactToEntitlement.put(ec.ContactId, ec.EntitlementId);
            }
        }

        // Stamp EntitlementId before insert DML commits
        for (Case c : newCases) {
            if (c.EntitlementId == null && contactToEntitlement.containsKey(c.ContactId)) {
                c.EntitlementId = contactToEntitlement.get(c.ContactId);
            }
        }
    }
}

Why not the alternative: Doing this in After Insert requires a follow-up update DML call on the same cases, which consumes extra DML statements and can re-trigger downstream logic. Before Insert field assignment avoids the extra DML.


Pattern 3: Distinguishing Merge Deletes from True Deletes in a Case Trigger

When to use: Logic in an after delete trigger on Case must behave differently for merged records (which have a surviving master) versus permanently deleted records.

How it works: Check MasterRecordId on the losing record in after delete. A non-null MasterRecordId indicates a merge; a null value indicates a true delete. The field is not yet populated in before delete, so the branch must live in the after context.

trigger CaseTrigger on Case (after delete) {
    if (Trigger.isAfter && Trigger.isDelete) {
        CaseTriggerHandler.onAfterDelete(Trigger.old);
    }
}
public with sharing class CaseTriggerHandler {

    public static void onAfterDelete(List<Case> oldCases) {
        Map<Id, Id> mergedIntoMaster = new Map<Id, Id>();
        List<Case> trueDeletes = new List<Case>();

        for (Case c : oldCases) {
            if (c.MasterRecordId != null) {
                // Merge — c was absorbed by c.MasterRecordId
                mergedIntoMaster.put(c.Id, c.MasterRecordId);
            } else {
                trueDeletes.add(c);
            }
        }

        if (!mergedIntoMaster.isEmpty()) {
            // Re-point anything the platform did not reparent automatically
        }
        if (!trueDeletes.isEmpty()) {
            // Permanent-delete logic (archival, integration notifications)
        }
    }
}

Why not the alternative: Putting this branch in before delete compiles and runs but is dead code — MasterRecordId is null there for every record, so merged cases take the true-delete path and the guard silently does nothing. Omitting the check entirely has the same effect: merge-delete logic runs the permanent-delete path, purging data that should have been migrated to the master record or firing cleanup workflows on records that still have a surviving counterpart.

If work genuinely must happen before the delete commits, do it unconditionally in before delete (you cannot know the master there) and reconcile in after delete once MasterRecordId is available.


Pattern 4: Completing Open Milestones When a Case Closes

When to use: Business rules require all open entitlement process milestones to be marked complete when a case is set to a closed status.

How it works: In After Update, detect cases transitioning to IsClosed = true, query their open CaseMilestone records, and set CompletionDate to close them.

public with sharing class CaseTriggerHandler {

    public static void completeMilestonesOnClose(
            List<Case> newCases, Map<Id, Case> oldMap) {
        Set<Id> closingCaseIds = new Set<Id>();
        for (Case c : newCases) {
            if (c.IsClosed && !oldMap.get(c.Id).IsClosed) {
                closingCaseIds.add(c.Id);
            }
        }
        if (closingCaseIds.isEmpty()) return;

        List<CaseMilestone> toComplete = [
            SELECT Id, CompletionDate
            FROM CaseMilestone
            WHERE CaseId IN :closingCaseIds
              AND IsCompleted = false
        ];

        if (toComplete.isEmpty()) return;

        Datetime now = Datetime.now();
        for (CaseMilestone cm : toComplete) {
            cm.CompletionDate = now;
        }
        update toComplete;
    }
}

Why not the alternative: Leaving milestones open after a case closes breaks SLA reporting, causes milestone violation alerts on closed cases, and produces inaccurate entitlement process statistics. The platform's asynchronous milestone evaluation does not guarantee completion in the same transaction.


Decision Guidance

SituationRecommended ApproachReason
Need assignment rules to fire when inserting cases from ApexDatabase.DmlOptions with useDefaultRule = trueDML keyword silently bypasses rules; Database.insert() with options is required
Need a specific assignment rule, not the active oneassignmentRuleHeader.assignmentRuleIdAllows pinning to a rule by Id; useful in multi-rule orgs
Entitlement coverage is account-wideRely on platform auto-associationPlatform can resolve entitlement by Account without a trigger
Entitlement coverage is contact-specific (EntitlementContact)Before Insert trigger querying EntitlementContactPlatform cannot resolve without the junction query
Delete trigger must distinguish merge from true deleteCheck MasterRecordId on Trigger.old in after deleteOnly reliable way; no dedicated merge event exists, and the field is null in before delete
Milestones must be completed when case closesAfter Update trigger setting CaseMilestone.CompletionDatePlatform does not auto-complete milestones in the same transaction as case close
Existing trigger framework in orgAdd new Case logic to existing handlerOne trigger per object; do not create a second CaseTrigger

Step-by-step instructions for an AI agent or practitioner working on this task:

  1. Audit the org context — Check whether a Case trigger already exists. If one does, add new logic inside the existing handler. Confirm whether assignment rules, entitlements, and entitlement processes are active. Identify which of the four patterns (assignment rules, entitlement association, merge handling, milestone completion) are in scope.
  2. Implement assignment rule invocation — If cases are inserted or updated via Apex and must respect assignment rules, switch from the insert/update keyword to Database.insert()/Database.update() with Database.DmlOptions set to useDefaultRule = true (or a specific rule Id). Apply this in the service or handler layer, not in the trigger body itself.
  3. Implement entitlement association — If contact-specific entitlement coverage is required, add a Before Insert (and optionally Before Update) handler that queries EntitlementContact for the case's ContactId and stamps Case.EntitlementId before the DML commits. Guard with a null check to avoid overwriting existing entitlement assignments.
  4. Implement merge delete handling — If delete-trigger logic must treat merged records differently from permanent deletes, add a null check on MasterRecordId in the after delete context. Do not place this check in before delete: the platform sets MasterRecordId between the two events, so the before-context check always reads null. Document this guard explicitly with a comment so future maintainers understand the intent.
  5. Implement milestone completion — If milestones must complete at case close, add an After Update handler that detects the IsClosed flip, queries open CaseMilestone records for the affected cases, and sets CompletionDate to Datetime.now().
  6. Write test coverage — Test each pattern independently: insert a case and assert the owner changed (assignment rule); insert a case with a ContactId linked to an EntitlementContact and assert EntitlementId is set; perform a merge and assert MasterRecordId behavior in delete context; close a case and assert CaseMilestone.IsCompleted is true.
  7. Run validation — Execute python3 scripts/skill_sync.py --skill skills/apex/case-trigger-patterns and confirm no errors before marking work complete.

Review Checklist

Run through these before marking work in this area complete:


Salesforce-Specific Gotchas

  1. Apex DML silently bypasses case assignment rules — Using the insert or update keyword (rather than Database.insert()/Database.update() with options) does not invoke assignment rules. The case is created without routing. There is no error or warning — the silent failure only surfaces when cases remain unassigned in production. Always use Database.DmlOptions when assignment rule evaluation is required.
  2. Closing a case does not auto-complete its milestones — When Case.Status transitions to a closed value, open CaseMilestone records for that case are not automatically set to IsCompleted = true. The entitlement process evaluates milestones asynchronously; in the same transaction as the close, milestones remain open. Any logic that reads milestone completion status in the same transaction as the close will see stale data unless the trigger explicitly updates CaseMilestone.CompletionDate.
  3. Merge fires delete triggers, not a merge event — There is no ismerge or mergeResult context in an Apex trigger. The only way to detect a merge inside a delete trigger is to check MasterRecordId on the losing record, and that check works only in after delete — "The MasterRecordId field is only set in after delete trigger events." Triggers that perform cleanup or archival on delete without this check, or that place the check in before delete, will incorrectly process merged records as permanently deleted.
  4. Apex merge supports cases; the SOAP merge() call does not — The Apex Developer Guide states "Only leads, contacts, cases, and accounts can be merged," so merge masterCase duplicateCase; is valid Apex. The SOAP API is narrower: its merge() call documents "The supported object types are Lead, Contact, Account, Person Account, and Individual" — Case is absent. An integration that merges cases through the SOAP API cannot do so; move the merge into Apex or use the Lightning case merge UI. Both surfaces cap a single merge at three records — Apex: "You can pass a main record and up to two additional sObject records to a single merge method"; SOAP: "Up to three records can be merged in a single request, including the main record."

Output Artifacts

ArtifactDescription
CaseTrigger.triggerMinimal trigger body routing to handler by context
CaseTriggerHandler.clsHandler class with methods for each pattern: associateEntitlements, completeMilestonesOnClose, onAfterDelete carrying the MasterRecordId merge guard
CaseService.clsOptional service class encapsulating Database.insert() with DmlOptions for cases requiring assignment rule invocation
CaseTriggerHandlerTest.clsTest class covering all four patterns with assertions on owner, entitlement, merge behavior, and milestone completion

Package files