SfSkills Domains Docs GitHub

Home / apex / apex-class-decomposition-pattern

apex-class-decomposition-pattern

When and how to split an Apex class into Domain / Service / Selector layers using this repo's lightweight base classes (BaseDomain, BaseService, BaseSelector). Covers splitting signals, ordering of extraction, and naming conventions. NOT for a full fflib migration — use apex/fflib-enterprise-patterns. NOT for choosing a trigger handler framework — use apex/trigger-framework.

Open the skill folder on GitHub


Apex Class Decomposition Pattern

Activate this skill when an Apex class has grown past the point where one class can comfortably hold all its responsibilities, when a trigger handler embeds SOQL, or when a service class has started issuing its own queries. The goal is the lightweight enterprise pattern this repo standardises on: a clear split into Trigger handler → Domain → Service → Selector, anchored by the canonical base classes in templates/apex/.

This is NOT a full fflib migration (no Application factory, Unit of Work, or interface registries) — see apex/fflib-enterprise-patterns for that. It is also NOT a trigger framework selection skill — see apex/trigger-framework for choosing between TriggerHandler styles.

The four roles

RoleResponsibilityMust NOT do
Trigger handlerEvent dispatch — route before insert / after update / etc. to a Domain or Service methodHold business logic, run SOQL, do DML
Domain (<X>Domain extends BaseDomain)Per-record validation and field derivation against a homogeneous Trigger.new collectionSOQL, DML, callouts
Service (<X>Service extends BaseService)Orchestration, transactions (savepoints), DML, platform events, calloutsIssue SOQL directly (always go through a Selector)
Selector (<X>Selector extends BaseSelector)All SOQL for a given SObject, named by intent (selectActiveByOwner(...))DML, business rules, mutating state

The base classes already encode the safety rails: BaseService exposes beginTransaction/rollbackTransaction/logAndRethrow, BaseSelector defaults to AccessLevel.USER_MODE, and BaseDomain operates over List<SObject> plus optional oldMap only.

Splitting signals

Split when any of these are true:

Extension and naming rules

When NOT to split

Ordering of split (lowest risk first)

  1. Extract the Selector first. SOQL has the cleanest seam: move every [SELECT ...] to <X>Selector methods named by intent. No behaviour change, easiest to verify.
  2. Extract the Service next. Move orchestration, DML, and savepoint handling into a <X>Service that calls the new Selector. Use BaseService.beginTransaction() / logAndRethrow().
  3. Extract the Domain last. Pull per-record validation and field derivation into <X>Domain operating on Trigger.new (and Trigger.oldMap when needed).

Reversing this order risks moving logic before its data dependencies are clear.

Stateful vs stateless

All three layers are stateless across invocations:

State that must persist belongs in a Custom Setting / CMDT / Platform Cache, not on a Service or Selector instance.

Anti-pattern: the "Manager" class

A class named AccountManager that opens savepoints, runs SOQL, validates per-record fields, and updates Contacts is doing all four roles at once. It is unbulkable, untestable in isolation, and a magnet for further bloat. Split immediately along the four-role boundary above.

  1. Inventory the target class — count lines, list SObjects touched, mark every [SELECT, every DML statement, every per-record loop with validation, and every callout.
  2. Classify each block by role (Trigger dispatch / Domain / Service / Selector) and confirm the split is justified by the signals above; if not, stop.
  3. Extract the Selector first — create <X>Selector extends BaseSelector with intent-named methods, replace inline SOQL with calls to it, run tests.
  4. Extract the Service next — create <X>Service extends BaseService, move DML/orchestration/savepoint handling, ensure SOQL only flows through the Selector.
  5. Extract the Domain last — create <X>Domain extends BaseDomain, move per-record validation and derivation, ensure no SOQL/DML leaks in.
  6. Wire the trigger handler to dispatch to Domain (for before validation/derivation) and Service (for after orchestration); keep the handler logic-free.
  7. Re-run the project test suite; run scripts/check_apex_class_decomposition_pattern.py to confirm no Selector mutates and no handler still embeds SOQL.

Review Checklist

Output Artifacts

ArtifactDescription
Split planMapping from old class blocks to new Domain / Service / Selector classes
New class shells<X>Domain, <X>Service, <X>Selector files extending the canonical base classes
Updated trigger handlerDispatch-only handler routing to the new layers
Decomposition checker runscripts/check_apex_class_decomposition_pattern.py clean exit

Package files