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
| Role | Responsibility | Must NOT do |
|---|---|---|
| Trigger handler | Event dispatch — route before insert / after update / etc. to a Domain or Service method | Hold business logic, run SOQL, do DML |
Domain (<X>Domain extends BaseDomain) | Per-record validation and field derivation against a homogeneous Trigger.new collection | SOQL, DML, callouts |
Service (<X>Service extends BaseService) | Orchestration, transactions (savepoints), DML, platform events, callouts | Issue 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:
- The class is over 400 lines and trending upward.
- A single class issues SOQL, performs DML, validates per-record state, AND orchestrates cross-object work.
- The same SObject is queried from 3+ classes with copy-pasted SOQL — Selector overdue.
- Business logic lives directly inside a
triggerbody or inside a handler that also runs SOQL. - Test setup is dominated by mocking unrelated concerns (signals tangled responsibilities).
- A "Manager" / "Util" / "Helper" suffix appears and the class touches more than two SObjects.
Extension and naming rules
AccountsDomain extends BaseDomain— always plural SObject +Domain.AccountMergeService extends BaseService— verb phrase +Service; one service = one cohesive use case.AccountsSelector extends BaseSelector— plural SObject +Selector, one Selector per SObject.- Trigger handlers stay in their own
<X>TriggerHandlerclass and own only the dispatch table. - Cross-Service calls go through interfaces, not concrete types, so dependencies stay testable.
When NOT to split
- Throwaway one-off utilities (data fix scripts, one-time migrations).
- Prototypes still in flux where the responsibilities have not yet stabilised.
- Sub-100-line classes with a single responsibility — splitting just adds ceremony.
- Pure invocable wrappers around a single Service call.
Ordering of split (lowest risk first)
- Extract the Selector first. SOQL has the cleanest seam: move every
[SELECT ...]to<X>Selectormethods named by intent. No behaviour change, easiest to verify. - Extract the Service next. Move orchestration, DML, and savepoint handling into a
<X>Servicethat calls the new Selector. UseBaseService.beginTransaction()/logAndRethrow(). - Extract the Domain last. Pull per-record validation and field derivation into
<X>Domainoperating onTrigger.new(andTrigger.oldMapwhen needed).
Reversing this order risks moving logic before its data dependencies are clear.
Stateful vs stateless
All three layers are stateless across invocations:
- Selectors take arguments, return query results, hold no caches.
- Services receive a request object (or arguments) per call and return a response — no instance fields holding mid-flight state.
- Domains hold the records they were constructed with for the duration of one trigger context only — they are not reused across transactions.
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.
Recommended Workflow
- Inventory the target class — count lines, list SObjects touched, mark every
[SELECT, every DML statement, every per-record loop with validation, and every callout. - Classify each block by role (Trigger dispatch / Domain / Service / Selector) and confirm the split is justified by the signals above; if not, stop.
- Extract the Selector first — create
<X>Selector extends BaseSelectorwith intent-named methods, replace inline SOQL with calls to it, run tests. - Extract the Service next — create
<X>Service extends BaseService, move DML/orchestration/savepoint handling, ensure SOQL only flows through the Selector. - Extract the Domain last — create
<X>Domain extends BaseDomain, move per-record validation and derivation, ensure no SOQL/DML leaks in. - Wire the trigger handler to dispatch to Domain (for
beforevalidation/derivation) and Service (forafterorchestration); keep the handler logic-free. - Re-run the project test suite; run
scripts/check_apex_class_decomposition_pattern.pyto confirm no Selector mutates and no handler still embeds SOQL.
Review Checklist
- [ ] Every
[SELECTlives in a<X>Selectormethod named by intent - [ ] No DML inside any class extending
BaseSelector - [ ] No SOQL/DML/callouts inside any class extending
BaseDomain - [ ] Trigger handler delegates only — no business rules in the handler body
- [ ] Service classes use
BaseService.beginTransaction()for transactional work - [ ] No "Manager" / "Util" class still bundling all four roles
- [ ] Cross-Service calls go through interfaces, not concrete classes
- [ ] All three layers remain stateless across invocations
Output Artifacts
| Artifact | Description |
|---|---|
| Split plan | Mapping 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 handler | Dispatch-only handler routing to the new layers |
| Decomposition checker run | scripts/check_apex_class_decomposition_pattern.py clean exit |
Related Skills
apex/trigger-framework— choosing the trigger handler styleapex/fflib-enterprise-patterns— full-fat enterprise pattern (Application, UoW, mocks)apex/apex-savepoint-and-rollback— transaction boundaries inside a Serviceapex/apex-test-data-factory— testing strategy after decomposition