apex-collections-patterns

Use when designing, reviewing, or debugging Apex code that relies on List, Set, or Map collections in triggers, batch classes, or service layers — especially for bulkification, heap management, and safe null handling. Trigger keywords: 'Map<Id, SObject>', 'containsKey', 'retainAll', 'putAll', 'Set intersection', 'heap limit', 'collection in loop', 'unbounded accumulation'. NOT for SOQL query optimization (use soql-fundamentals), NOT for async job design (use apex-queueable-patterns or batch-apex-patterns), NOT for platform cache strategies (use platform-cache).

Best use case

apex-collections-patterns is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Use when designing, reviewing, or debugging Apex code that relies on List, Set, or Map collections in triggers, batch classes, or service layers — especially for bulkification, heap management, and safe null handling. Trigger keywords: 'Map<Id, SObject>', 'containsKey', 'retainAll', 'putAll', 'Set intersection', 'heap limit', 'collection in loop', 'unbounded accumulation'. NOT for SOQL query optimization (use soql-fundamentals), NOT for async job design (use apex-queueable-patterns or batch-apex-patterns), NOT for platform cache strategies (use platform-cache).

Teams using apex-collections-patterns should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/apex-collections-patterns/SKILL.md --create-dirs "https://raw.githubusercontent.com/PranavNagrecha/AwesomeSalesforceSkills/main/skills/apex/apex-collections-patterns/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/apex-collections-patterns/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How apex-collections-patterns Compares

Feature / Agentapex-collections-patternsStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Use when designing, reviewing, or debugging Apex code that relies on List, Set, or Map collections in triggers, batch classes, or service layers — especially for bulkification, heap management, and safe null handling. Trigger keywords: 'Map<Id, SObject>', 'containsKey', 'retainAll', 'putAll', 'Set intersection', 'heap limit', 'collection in loop', 'unbounded accumulation'. NOT for SOQL query optimization (use soql-fundamentals), NOT for async job design (use apex-queueable-patterns or batch-apex-patterns), NOT for platform cache strategies (use platform-cache).

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

Related Guides

SKILL.md Source

# Apex Collections Patterns

Use this skill when reviewing or writing Apex code that uses List, Set, or Map to aggregate, de-duplicate, or look up SObject data in triggers, batch classes, or service layers. The skill covers safe null handling, heap-efficient accumulation patterns, and idiomatic bulkification using Map<Id, List<SObject>>.

---

## Before Starting

Gather this context before working on anything in this domain:

- Is the code running in a trigger (200-record scope), a batch execute() chunk (configurable scope), or synchronous Apex? The heap pressure and loop patterns differ.
- Is the class implementing Database.Stateful? If so, any instance-level Map or List grows across every execute() chunk and can exhaust the 6 MB heap before the job finishes.
- Are Set intersection or subtraction operations needed? If so, confirm whether mutating the receiver is acceptable — `retainAll()` and `removeAll()` modify the Set in place.
- What is the maximum expected volume of records? A Map keyed on Id with one value per key is O(n); a Map<Id, List<SObject>> with unbounded inner lists can be O(n²) if records share the same key frequently.

---

## Core Concepts

### Collections Are Heap-Allocated Against the 6 MB Limit

Every List, Set, and Map in an Apex transaction contributes to the heap. A Map<Id, List<SObject>> is the standard bulkification container in trigger handlers: one SOQL returns all related records, and the Map groups them by parent Id. However, each inner List object also consumes heap. In a `Database.Stateful` batch job, instance-level Map or List fields persist across every `execute()` call — growing unboundedly until the job finishes or the 6 MB limit kills the transaction. The Apex Developer Guide states the heap limit as 6 MB for synchronous transactions and async transactions alike.

### Map.get() Returns null — Not an Exception

`Map.get(key)` returns `null` when the key is absent. This is different from Java's behavior and different from what many LLMs assume. Calling `.size()`, iterating, or performing any operation on a `null` return causes a `NullPointerException`. The correct guard is `Map.containsKey(key)` before `Map.get(key)`, or assigning to a variable and null-checking before use. This applies equally to `Trigger.oldMap.get(Id)` inside after-update triggers.

### Set Mutation — retainAll() and removeAll() Are In-Place

`Set.retainAll(otherCollection)` modifies the receiver Set to keep only elements present in both collections (intersection). `Set.removeAll(otherCollection)` removes all elements present in the argument (subtraction). Both are destructive to the original Set. If the original Set is needed after the operation, copy it first with `new Set<Id>(originalSet)` before calling `retainAll()` or `removeAll()`. Building a new Set manually in a loop instead of calling `retainAll()` is verbose, slower, and a frequent LLM anti-pattern.

### putAll(List<SObject>) Keys on the SObject Id Field

`Map<Id, SObject>.putAll(List<SObject>)` inserts all records into the Map using each record's `Id` field as the key. Records with a null `Id` (unsaved records) will cause a `NullPointerException`. Records with duplicate Ids — possible in Trigger.new on update when the same record appears — will silently overwrite the prior entry. This is intentional behavior for trigger maps (most recent value wins) but can be surprising in other contexts.

---

## Common Patterns

### Map<Id, List<SObject>> for Bulkified Trigger Lookups

**When to use:** An after-insert or after-update trigger on a child object needs to group child records by their parent Id before performing a single DML or SOQL operation at the parent level.

**How it works:**
1. Query all relevant parent records using the set of parent Ids extracted from `Trigger.new`.
2. Build a `Map<Id, List<Child__c>>` by iterating the query results once, using `Map.containsKey()` guard before `Map.get()`.
3. Iterate `Trigger.new`, look up each record's parent group from the Map, and accumulate changes.
4. Perform a single bulkified DML call outside all loops.

Reference the `templates/apex/TriggerHandler.cls` scaffold for the handler structure. The collection building belongs in the handler's `afterInsert()` / `afterUpdate()` methods, not in a trigger body directly.

**Why not the alternative:** Querying inside a for loop over `Trigger.new` runs one SOQL per record, burning the 100-query limit on any bulk load of 100+ records.

### Safe Set Intersection With retainAll()

**When to use:** A service method needs to find the overlap between two Sets — for example, the set of record Ids that are both in a new batch and in an existing do-not-process exclusion list.

**How it works:**
1. Construct the first Set from the incoming Ids: `Set<Id> incoming = new Set<Id>(triggerIds);`
2. Construct or load the exclusion Set from a SOQL or Custom Metadata query.
3. Create a working copy if the original Set must be preserved: `Set<Id> overlap = new Set<Id>(incoming);`
4. Call `overlap.retainAll(exclusionIds);` — the result is the intersection in one platform operation.
5. Subtract from the working set to get records that are NOT excluded: `incoming.removeAll(exclusionIds);`

**Why not the alternative:** Building a new Set by iterating and adding manually is O(n) extra code, allocates additional intermediate objects, and is more likely to introduce off-by-one bugs.

---

## Decision Guidance

| Situation | Recommended Approach | Reason |
|---|---|---|
| Group child records by parent in a trigger | `Map<Id, List<SObject>>` built outside loops | Single pass; avoids SOQL in loop |
| Check if a key exists before reading its value | `Map.containsKey(key)` guard before `Map.get(key)` | `Map.get()` returns null — not an exception |
| Find the overlap between two Id Sets | `Set.retainAll()` on a copy | One platform call; avoids manual loop |
| Convert a query result to a lookup map | `Map<Id, SObject> m = new Map<Id, SObject>(queryResult)` | Map constructor with List<SObject> — idiomatic and concise |
| Accumulate state across Batch execute() chunks | Write results to SObject records at end of each chunk; avoid growing instance-level Maps | Instance-level collections in Database.Stateful grow unboundedly and exhaust heap |
| De-duplicate a List of Ids | `new Set<Id>(myList)` | Set construction removes duplicates in one step |
| Sort a List of custom objects | Implement `Comparator<T>` interface (Spring '24+) | Platform-native sort; avoids hand-rolled comparison logic |

---

## Recommended Workflow

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

1. Identify the Apex context — trigger, batch, or service — and note the scope size and whether `Database.Stateful` is involved.
2. Locate every collection declaration and flag any that are instance-level fields in a `Database.Stateful` class; these are candidates for heap exhaustion.
3. Find every `Map.get()` call and verify a `Map.containsKey()` guard or null-check precedes it; unguarded calls will throw `NullPointerException` when the key is absent.
4. Find every `Set.retainAll()` or `Set.removeAll()` call and confirm the caller does not expect the original Set to be unmodified; if it does, the Set must be copied first.
5. Verify that SOQL queries and DML statements are outside all for loops; collections should accumulate changes across the loop, with a single bulk operation after.
6. Run `python3 scripts/check_apex_collections_patterns.py --manifest-dir <path>` against the target metadata directory.
7. Review the output artifacts against the Review Checklist below before closing the task.

---

## Review Checklist

- [ ] All `Map.get()` calls are preceded by `Map.containsKey()` or the return value is null-checked before use.
- [ ] No `Database.Stateful` batch class accumulates unbounded Map or List values across `execute()` chunks.
- [ ] `Set.retainAll()` and `Set.removeAll()` are called on copies, not the original collection, when the original is needed afterward.
- [ ] No SOQL query or DML statement appears inside a for loop.
- [ ] `Map<Id, SObject>` construction from `List<SObject>` uses the Map constructor (`new Map<Id, SObject>(list)`) rather than a manual loop where possible.
- [ ] Inner Lists in `Map<Id, List<SObject>>` are initialized with `containsKey` guard (not overwriting an existing list).
- [ ] Any `putAll(List<SObject>)` call is used only on records with guaranteed non-null Ids.

---

## Salesforce-Specific Gotchas

1. **Map.get() returns null silently** — unlike Java's optional approach, Apex `Map.get()` returns `null` for a missing key with no exception. Code that chains `.size()` or iterates the result without a null guard will throw `NullPointerException` at runtime — not at compile time — and only on data paths where the key is absent.

2. **Database.Stateful instance collections grow across every execute() chunk** — if a `Database.Stateful` batch class declares a `Map<Id, List<SObject>>` or `List<SObject>` as an instance field, the collection grows with every chunk processed. For a 200-scope batch over 100,000 records, the collection accumulates data from 500 chunks before `finish()` runs. Heap exhaustion causes the entire job to fail with a `LimitException` and no partial rollback. The fix is to flush accumulated data to the database at the end of each `execute()` chunk and keep only lightweight counters in instance fields.

3. **putAll(List<SObject>) silently overwrites duplicate keys** — `Map.putAll(list)` uses each SObject's `Id` as the key. If two records in the list share an Id (possible in upsert scenarios or test data with re-used Ids), the later record silently replaces the earlier one. This produces data loss bugs that are difficult to reproduce in unit tests where each test record has a unique fake Id.

4. **retainAll() and removeAll() mutate the receiver** — calling `mySet.retainAll(otherSet)` changes `mySet` in place. Code that passes a Set to a helper method and then continues to use the Set after the helper called `retainAll()` internally will observe a mutated Set with no indication that mutation occurred. This is a silent logic bug, not an exception.

5. **Set construction from a List does not preserve order** — `new Set<String>(myList)` de-duplicates but does not guarantee insertion order. Code that converts a List to a Set for de-duplication and then iterates the Set expecting the original order will produce non-deterministic behavior across Salesforce releases.

---

## Output Artifacts

| Artifact | Description |
|---|---|
| Collection pattern review | Findings on Map null-guard coverage, Set mutation safety, heap accumulation risk, and DML/SOQL loop violations |
| Bulkified trigger handler skeleton | Map<Id, List<SObject>> pattern with null-safe get and single-pass DML, referencing templates/apex/TriggerHandler.cls |
| Batch heap remediation plan | Identifies unbounded instance-level collections in Database.Stateful classes and recommends flush-per-chunk pattern |

---

## Related Skills

- `apex/trigger-framework` — use when the handler structure around the collection patterns is the primary concern.
- `apex/batch-apex-patterns` — use when the broader batch design (scope, start/execute/finish, error handling) is the focus.
- `apex/governor-limits` — use when heap or CPU limits are being hit and broader limit strategy is needed.
- `apex/soql-fundamentals` — use when the underlying SOQL driving collection population needs optimization.
- `apex/exception-handling` — use when NullPointerException from unguarded Map.get() is part of a broader error handling review.

Related Skills

mfa-enforcement-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Design MFA enforcement: auto-enablement, Salesforce Authenticator rollout, exceptions, service accounts, API-only users, SSO interop, and audit. Trigger keywords: MFA, multi-factor, two-factor, Salesforce Authenticator, MFA exception, MFA SSO, api-only MFA. Does NOT cover: end-user password policies, device-trust posture, or non-Salesforce IdP configuration.

encrypted-field-query-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Design SOQL, filters, reporting, and indexes against Shield Platform Encryption fields. Trigger keywords: Shield Platform Encryption, encrypted field query, probabilistic vs deterministic encryption, encrypted SOQL filter, encrypted field index. Does NOT cover: Classic Encryption (deprecated), field-level security policy, or tenant secret key rotation.

apex-managed-sharing-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Grant row-level access programmatically via __Share records when declarative sharing rules cannot express the policy. NOT for OWD, role hierarchy, or criteria-based sharing rule design.

omnistudio-testing-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Use when testing or validating OmniStudio components — OmniScript preview, Integration Procedure step debugging, DataRaptor field-mapping validation, and end-to-end UTAM-based automation. NOT for Apex unit testing or standard Flow debugging.

omnistudio-error-handling-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Use when designing fault behavior across Integration Procedures, DataRaptors, OmniScripts, and FlexCards — error routing, user-facing messaging, retry semantics, and idempotency. Triggers: 'omnistudio error', 'integration procedure fault', 'dataraptor error handling', 'omniscript retry', 'flexcard action failure'. NOT for general Apex exception design or Flow fault paths.

omnistudio-ci-cd-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Use when designing or implementing CI/CD pipelines for OmniStudio components — DataPack export/import, versioning, environment promotion, and automated deployment. NOT for standard Salesforce metadata CI/CD or Apex-only pipelines.

omniscript-design-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Use when designing or reviewing OmniScripts for guided experiences, step structure, branching, save/resume, and the boundary between OmniScript, Integration Procedures, DataRaptors, and custom LWCs. Triggers: 'omniscript design', 'too many steps in omniscript', 'save and resume omniscript', 'branching in omniscript', 'when should this be an integration procedure'. NOT for deep Integration Procedure or DataRaptor design when the guided interaction layer is not the main concern.

integration-procedure-cacheable-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Use when designing Integration Procedures (IPs) with platform cache to cut latency and callout load. Covers cache key design, TTL selection, per-user vs org-wide partitions, invalidation on data changes, and safe fallback on cache miss/stale. Does NOT cover general IP authoring (see omnistudio-error-handling-patterns) or LWC client-side caching.

flexcard-design-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Use when designing, building, or reviewing OmniStudio FlexCards — including data source selection, card states, actions, conditional visibility, flyout configuration, and child card iteration. Triggers: 'FlexCard', 'card template', 'flyout', 'card action', 'card state', 'data source', 'child card', 'conditional visibility'. NOT for OmniScript design, standalone LWC development, or Apex controller architecture outside the FlexCard context.

dataraptor-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Use when designing or reviewing OmniStudio DataRaptors, especially Extract versus Turbo Extract versus Transform versus Load, field mapping strategy, performance tradeoffs, and when to move work into Integration Procedures or Apex. Triggers: 'DataRaptor Extract', 'Turbo Extract', 'DataRaptor Load', 'DataRaptor Transform', 'OmniStudio data mapping'. NOT for overall OmniScript journey design or Integration Procedure sequencing when the main question is not the DataRaptor shape itself.

wire-service-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Use when designing or reviewing Lightning Web Components that use `@wire`, Lightning Data Service, UI API, or the GraphQL wire adapter, especially for reactive parameters, cache behavior, and refresh strategy. Triggers: 'wire service', 'refreshApex', 'reactive parameter', 'getRecord', 'wire vs imperative Apex'. NOT for component communication or generic lifecycle issues when data provisioning is not the main concern.

message-channel-patterns

8
from PranavNagrecha/AwesomeSalesforceSkills

Use when implementing Lightning Message Service (LMS) to enable cross-DOM communication between LWC, Aura, and Visualforce components on the same Lightning page, using message channels. Triggers: 'communicate between unrelated LWC components', 'send data between Visualforce and LWC', 'lightning message service not working', 'APPLICATION_SCOPE vs default scope', 'message channel metadata deployment'. NOT for parent-child component communication (use component-communication) or server-side events.