SfSkills Domains Docs GitHub

Home / apex / apex-design-patterns

apex-design-patterns

Use when structuring Apex into service, selector, domain, factory, and dependency-injection layers for maintainability and testability. Triggers: 'service layer', 'selector pattern', 'domain layer', 'dependency injection', 'fat trigger/controller', 'unit of work', 'strategy pattern in Apex', 'Type.forName factory', 'static cache singleton', 'savepoint rollback in a service'. NOT for the fflib library — use apex/fflib-enterprise-patterns. NOT for splitting one class — use apex/apex-class-decomposition-pattern.

Open the skill folder on GitHub


Use this skill when Apex code needs structure that will survive more than one sprint. The goal is not to import a framework blindly. It is to separate orchestration, querying, business rules, and replaceable dependencies so triggers, controllers, and invocables stay thin and tests can isolate behavior.

The repo already owns the base classes. Do not re-implement them — extend them: templates/apex/TriggerHandler.cls, templates/apex/TriggerControl.cls, templates/apex/BaseDomain.cls, templates/apex/BaseService.cls, templates/apex/BaseSelector.cls, templates/apex/ApplicationLogger.cls, templates/apex/SecurityUtils.cls, templates/apex/tests/TestDataFactory.cls. references/code-examples.md shows a concrete Case stack built on exactly those files.

Before Starting

Questions to Ask Before Configuring

Ask these before writing a class. Each one maps to a gotcha in references/gotchas.md; an agent that skips them produces a layer diagram that compiles and a transaction that misbehaves under Bulk API or a second save.

AskWhy it mattersWhat a good answer adds
"What is the largest batch this code will ever see — a UI save, a 200-row trigger chunk, or a Bulk API job?"Static guards and static caches are reset across transactions but not across the trigger invocations of one Bulk API request (Apex Developer Guide L3789–3791)The recursion-guard design, and whether the guard may be a Boolean at all
"Does any step of this workflow have to be undone if a later step fails?"Only a service may open a savepoint, and each savepoint costs one of the 150 DML statements (L8691, L19554)Whether you need BaseService.beginTransaction() at all, and where the try/catch boundary sits
"Which behaviours must differ per business unit, record type, or installed org?"That is the strategy seam — a Custom Metadata row naming an Apex class, resolved with Type.forName (Apex Reference Guide L242008–242049)The CMDT type, its rows, and the interface every implementation must satisfy
"Which of these classes is an entry point, and which is called by one?"inherited sharing runs as without sharing only when called from an established without sharing context; at every entry point it runs with sharing (L4851–4860)The sharing keyword per layer, written down rather than defaulted
"What API version will these classes be saved at?"At 65.0+ an abstract/override method without an explicit access modifier fails to compile (L3359–3364); at 67.0+ an undeclared class runs with sharing and Apex runs in user context (L4961, L11744–11746)The apiVersion in every .cls-meta.xml, chosen deliberately
"Who owns the field list this query returns, and does the caller need every field?"One selectEverything method becomes the slowest query in the org and the hardest to security-reviewIntent-named selector methods instead of one broad one
"Is this abstraction paying for itself today, or is it speculative?"Apex has no DI container; every seam is hand-wired and hand-maintainedAn explicit stop line — which collaborators get an interface and which stay concrete

What a proper configuration adds over just writing the classes: the trigger is re-entrant-safe under Bulk API chunking, a failed step rolls the whole unit of work back instead of leaving half a transaction committed, per-org behaviour changes by deploying a Custom Metadata row rather than an Apex class, and every layer's sharing and access-modifier decision is written in the source rather than inherited by accident.

Core Concepts

Service Layer Owns Orchestration

Service classes coordinate work. They should decide sequence, call selectors, invoke domain logic, and manage transaction boundaries. They should not absorb every query, validation rule, and integration detail forever. When a service becomes the only place logic can live, it turns into a god-class quickly.

templates/apex/BaseService.cls fixes the contract: beginTransaction() / rollbackTransaction(sp) / logAndRethrow(source, e). The service is the only layer allowed to call them.

Selector Layer Centralizes Query Intent

Selectors are for query shape, field lists, and reusable retrieval patterns. They make security review, field list reuse, and query tuning easier because data access is not scattered across controllers, triggers, and utility methods. A selector is not just "any class with SOQL"; it should have a stable retrieval responsibility.

templates/apex/BaseSelector.cls is abstract inherited sharing and hands subclasses userMode() / systemMode(), so AccessLevel.USER_MODE is the default and a system-mode query is a visible, reviewable exception.

Domain Layer Holds Object-Specific Rules

Domain logic is where object behavior and cross-field rules belong. If every trigger, flow-invocable method, and controller re-implements the same Account or Opportunity rules differently, domain logic is missing.

templates/apex/BaseDomain.cls carries the collection plus oldMap, and gives you isChanged(record, field) and recordIds(). Its contract is explicit: domains never issue SOQL, DML, or callouts.

Unit Of Work Is A Savepoint With A Boundary

Apex has no separate transaction object. The unit of work is the Apex transaction itself, narrowed by Database.setSavepoint() and Database.rollback(sp). Two platform facts shape the pattern:

So a savepoint belongs at one service method boundary, not around every DML.

Singleton Means "Static, For One Transaction"

A static variable is static only within the scope of one Apex transaction and is reset across transaction boundaries (L3736–3738). That makes a static Map a perfect per-transaction cache and a poor cross-request cache. Two consequences the catalogue depends on:

Strategy And Factory Are One Pattern On This Platform

Type.forName(name) returns the System.Type of a public or global class — never a private one (Apex Reference Guide L241920–241922) — and newInstance() creates an instance you cast to the interface (L242298–242327). Pair it with a Custom Metadata Type row holding the class name and you get behaviour that varies per org without an Apex deployment. Custom metadata records carry no SOQL-query limit inside a transaction (Apex Developer Guide L19614–19615), so the lookup is effectively free.

Dependency Injection Creates Testable Boundaries

Apex has limited native DI ergonomics compared with other languages, but interfaces plus factories still help. The purpose is not abstraction for its own sake. It is to replace integrations, notification clients, or expensive collaborators in tests without branching on Test.isRunningTest().

Common Patterns

Thin Entry Point To Service

When to use: Triggers, controllers, invocables, or REST resources are accumulating business logic.

How it works: Keep the entry point as an adapter only, then delegate to a service with explicit inputs. For triggers the adapter is one line — new CaseTriggerHandler().run(); — because templates/apex/TriggerHandler.cls already owns dispatch, the depth counter, skipOnce(), and the TriggerControl kill switch.

Why not the alternative: Entry-point logic is hard to reuse and harder to review across many contexts.

Service + Selector Pair

When to use: A business workflow reads complex record sets repeatedly.

How it works: Put orchestration in the service and field/query definitions in a selector.

Interface + Factory For External Dependencies

When to use: A service depends on a notifier, API client, or expensive collaborator.

How it works: Define an interface, provide a production implementation, and use a factory or constructor injection for tests.

Custom Metadata-Driven Strategy

When to use: The same step must behave differently per business unit, record type, or subscriber org, and you do not want a switch that grows a branch per customer.

How it works: One interface, one implementation class per variant, one ..._Strategy__mdt row per variant naming the class. The factory reads the row, calls Type.forName, casts to the interface, and memoises the instance in a static map for the transaction. Worked end to end in references/code-examples.md § 5–6.

Why not the alternative: A hardcoded if/else ships an Apex deployment for every new variant; the CMDT row ships as data.

Unit Of Work At The Service Boundary

When to use: One service method writes to more than one object and a partial write would be wrong.

How it works: Savepoint sp = beginTransaction(); at the top of the public method, all DML inside the try, rollbackTransaction(sp) plus logAndRethrow in the catch. One savepoint per public method, never one per DML.

Why not the alternative: Database.insert(records, false) gives you partial success, which is a different requirement — use it when partial writes are acceptable, not as a substitute for rollback.

Decision Guidance

SituationRecommended ApproachReason
Trigger or controller contains queries, branching, and DML directlyThin adapter + service layerCleaner review and reuse boundary
Same query field list appears in several classesSelector layerOne place to tune and secure data access
Object-specific business rules repeat across entry pointsDomain layerKeeps behavior tied to the object's business rules
Tests rely on Test.isRunningTest() to skip dependenciesInterface + injected dependencyBetter isolation without production branching
One method writes several objects and a partial write is unacceptableSavepoint at the service method boundaryRollback is a service responsibility; the DML statement cost is paid once
Some but not all rows may fail and that is acceptableDatabase.insert(rows, false) and inspect Database.SaveResultPartial success is not the same requirement as rollback
Behaviour must vary per business unit or subscriber orgInterface + CMDT row + Type.forName factoryNew variants ship as data, not as an Apex deployment
The same value is recomputed many times in one saveStatic map memoised in the classStatics live exactly one transaction (L3736–3738)
The "cache" must survive past the transactionPlatform Cache or a Custom Setting, not a staticA static is reset at every transaction boundary

Step-by-step instructions for an AI agent or practitioner activating this skill:

  1. Answer the seven questions above first. The batch-size answer decides the recursion guard, the rollback answer decides whether a savepoint exists at all, and the variability answer decides whether a strategy interface is justified. Write the answers into templates/apex-design-patterns-template.md before any class exists.
  2. Place every existing method into exactly one layer. Use the Decision Guidance table. Anything that lands in two layers is the refactor; anything that lands in none is usually a utility that should be inlined or deleted.
  3. Extend the canonical bases, never fork them. TriggerHandler for dispatch, BaseDomain for per-record rules, BaseSelector for every SOQL statement, BaseService for orchestration and the savepoint, SecurityUtils before DML on user-supplied rows. Copy the concrete shapes from references/code-examples.md § 1–4 rather than inventing new ones. Give every override an explicit protected/public modifier — API 65.0+ rejects it otherwise.
  4. Add the strategy seam only where question 3 said behaviour varies. Build it as references/code-examples.md § 5–6 does: interface, ..._Strategy__mdt row, Type.forName + newInstance() cast to the interface, memoised in a static map, with a named fallback when the row is missing or the class no longer exists.
  5. Write the test class against the three paths that break in production — 200 records through the trigger, each strategy resolved from its CMDT row, and the rollback path proving the savepoint undid every object the service touched. Build fixtures with templates/apex/tests/TestDataFactory.cls and the 200-row shape in templates/apex/tests/BulkTestPattern.cls.
  6. Run this skill's checker over the source tree, then the tests:

    python3 skills/apex/apex-design-patterns/scripts/check_apex_design_patterns.py --manifest-dir force-app/main/default then sf apex run test --tests CaseEscalationServiceTest --result-format human --code-coverage --synchronous.

  7. Record what you did not abstract and why. The template has a row for it. An unwritten "we deliberately left CaseSelector concrete" is re-litigated in every later review.

Review Checklist

Salesforce-Specific Gotchas

  1. A "service layer" can become a dumping ground fast — if every concern lands there, the pattern has failed.
  2. Selector patterns still need secure query behavior — sharing declarations do not enforce object- or field-level security at all (Apex Developer Guide L4926).
  3. Static helpers are not dependency injection — they are harder to stub and usually push teams toward test-only branching.
  4. Patterns are not free — for a tiny one-off class, excessive layering can be ceremony without payoff.
  5. A rollback does not undo your statics — the recursion guard you set before the failure is still set (L8692–8693).
  6. Bulk API chunking resets governor limits but not statics — a Boolean hasRun guard silently skips chunks 2..n of the same request (L3789–3791).
  7. Type.forName returns null for an inner or private class — the failure surfaces as a null dereference at newInstance(), not as a compile error (Apex Reference Guide L241920–241922).

Full What happens / When it occurs / How to avoid treatment for these and five more is in references/gotchas.md.

Output Artifacts

ArtifactDescription
Layering reviewFindings on where orchestration, queries, and business rules are misplaced
Refactor mapRecommendation for service, selector, domain, factory, and interface boundaries
Pattern scaffoldMinimal Apex structure showing how to separate responsibilities cleanly
Deployable stackTrigger + handler + domain + service + selector + strategy factory + CMDT + bulk test, with -meta.xml and package.xml

Reference Files

FileRead it when
references/code-examples.mdYou are about to write the classes — the full Case stack on the canonical bases, the CMDT strategy factory, the 200-record + rollback test, -meta.xml, package.xml, and the sf commands
references/gotchas.mdA layered design compiles but misbehaves — statics, savepoints, sharing modes, Type.forName, CMDT truncation, access modifiers
references/examples.mdYou want the shorter before/after refactor narratives and the layer-ownership matrix
references/llm-anti-patterns.mdYou are reviewing generated Apex and want the eight failure shapes with detection hints
references/well-architected.mdYou are tagging findings by pillar or need the sourced claim behind a statement

Package files