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
trigger body 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>TriggerHandler class 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>Selector methods named by intent. No behaviour change, easiest to verify.
- Extract the Service next. Move orchestration, DML, and savepoint handling into a
<X>Service that calls the new Selector. Use BaseService.beginTransaction() / logAndRethrow().
- 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:
- 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 BaseSelector with 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
before validation/derivation) and Service (for after orchestration); keep the handler logic-free.
- 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
| 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 style
apex/fflib-enterprise-patterns — full-fat enterprise pattern (Application, UoW, mocks)
apex/apex-savepoint-and-rollback — transaction boundaries inside a Service
apex/apex-test-data-factory — testing strategy after decomposition
1---2name: apex-class-decomposition-pattern3description: 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.4---56# Apex Class Decomposition Pattern78Activate 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/`.910This 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.1112## The four roles1314| Role | Responsibility | Must NOT do |15|---|---|---|16| Trigger handler | Event dispatch — route `before insert` / `after update` / etc. to a Domain or Service method | Hold business logic, run SOQL, do DML |17| Domain (`<X>Domain extends BaseDomain`) | Per-record validation and field derivation against a homogeneous `Trigger.new` collection | SOQL, DML, callouts |18| Service (`<X>Service extends BaseService`) | Orchestration, transactions (savepoints), DML, platform events, callouts | Issue SOQL directly (always go through a Selector) |19| Selector (`<X>Selector extends BaseSelector`) | All SOQL for a given SObject, named by intent (`selectActiveByOwner(...)`) | DML, business rules, mutating state |2021The 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.2223## Splitting signals2425Split when **any** of these are true:2627- The class is over **400 lines** and trending upward.28- A single class issues SOQL, performs DML, validates per-record state, AND orchestrates cross-object work.29- The same SObject is queried from **3+ classes** with copy-pasted SOQL — Selector overdue.30- Business logic lives directly inside a `trigger` body or inside a handler that also runs SOQL.31- Test setup is dominated by mocking unrelated concerns (signals tangled responsibilities).32- A "Manager" / "Util" / "Helper" suffix appears and the class touches more than two SObjects.3334## Extension and naming rules3536- `AccountsDomain extends BaseDomain` — always plural SObject + `Domain`.37- `AccountMergeService extends BaseService` — verb phrase + `Service`; one service = one cohesive use case.38- `AccountsSelector extends BaseSelector` — plural SObject + `Selector`, one Selector per SObject.39- Trigger handlers stay in their own `<X>TriggerHandler` class and own only the dispatch table.40- Cross-Service calls go through interfaces, not concrete types, so dependencies stay testable.4142## When NOT to split4344- Throwaway one-off utilities (data fix scripts, one-time migrations).45- Prototypes still in flux where the responsibilities have not yet stabilised.46- Sub-100-line classes with a single responsibility — splitting just adds ceremony.47- Pure invocable wrappers around a single Service call.4849## Ordering of split (lowest risk first)50511. **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.522. **Extract the Service next.** Move orchestration, DML, and savepoint handling into a `<X>Service` that calls the new Selector. Use `BaseService.beginTransaction()` / `logAndRethrow()`.533. **Extract the Domain last.** Pull per-record validation and field derivation into `<X>Domain` operating on `Trigger.new` (and `Trigger.oldMap` when needed).5455Reversing this order risks moving logic before its data dependencies are clear.5657## Stateful vs stateless5859All three layers are **stateless across invocations**:6061- Selectors take arguments, return query results, hold no caches.62- Services receive a request object (or arguments) per call and return a response — no instance fields holding mid-flight state.63- Domains hold the records they were constructed with for the duration of one trigger context only — they are not reused across transactions.6465State that must persist belongs in a Custom Setting / CMDT / Platform Cache, not on a Service or Selector instance.6667## Anti-pattern: the "Manager" class6869A 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.7071## Recommended Workflow72731. Inventory the target class — count lines, list SObjects touched, mark every `[SELECT`, every DML statement, every per-record loop with validation, and every callout.742. Classify each block by role (Trigger dispatch / Domain / Service / Selector) and confirm the split is justified by the signals above; if not, stop.753. Extract the Selector first — create `<X>Selector extends BaseSelector` with intent-named methods, replace inline SOQL with calls to it, run tests.764. Extract the Service next — create `<X>Service extends BaseService`, move DML/orchestration/savepoint handling, ensure SOQL only flows through the Selector.775. Extract the Domain last — create `<X>Domain extends BaseDomain`, move per-record validation and derivation, ensure no SOQL/DML leaks in.786. Wire the trigger handler to dispatch to Domain (for `before` validation/derivation) and Service (for `after` orchestration); keep the handler logic-free.797. 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.8081## Review Checklist8283- [ ] Every `[SELECT` lives in a `<X>Selector` method named by intent84- [ ] No DML inside any class extending `BaseSelector`85- [ ] No SOQL/DML/callouts inside any class extending `BaseDomain`86- [ ] Trigger handler delegates only — no business rules in the handler body87- [ ] Service classes use `BaseService.beginTransaction()` for transactional work88- [ ] No "Manager" / "Util" class still bundling all four roles89- [ ] Cross-Service calls go through interfaces, not concrete classes90- [ ] All three layers remain stateless across invocations9192## Output Artifacts9394| Artifact | Description |95|---|---|96| Split plan | Mapping from old class blocks to new Domain / Service / Selector classes |97| New class shells | `<X>Domain`, `<X>Service`, `<X>Selector` files extending the canonical base classes |98| Updated trigger handler | Dispatch-only handler routing to the new layers |99| Decomposition checker run | `scripts/check_apex_class_decomposition_pattern.py` clean exit |100101## Related Skills102103- `apex/trigger-framework` — choosing the trigger handler style104- `apex/fflib-enterprise-patterns` — full-fat enterprise pattern (Application, UoW, mocks)105- `apex/apex-savepoint-and-rollback` — transaction boundaries inside a Service106- `apex/apex-test-data-factory` — testing strategy after decomposition