# 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.

- Skill: `pranavnagrecha/apex-class-decomposition-pattern` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds add pranavnagrecha/apex-class-decomposition-pattern`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pranavnagrecha/apex-class-decomposition-pattern/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: PranavNagrecha (https://skillmd.com/u/pranavnagrecha)
- Updated: 2026-09-08
- Page: https://skillmd.com/skills/pranavnagrecha/apex-class-decomposition-pattern

---


# 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)

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**:

- 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

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

- [ ] Every `[SELECT` lives in a `<X>Selector` method 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 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

