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 full fflib migration — see fflib-enterprise-patterns. NOT for trigger framework choice — see trigger-framework.
Best use case
apex-class-decomposition-pattern is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
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 full fflib migration — see fflib-enterprise-patterns. NOT for trigger framework choice — see trigger-framework.
Teams using apex-class-decomposition-pattern should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/apex-class-decomposition-pattern/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How apex-class-decomposition-pattern Compares
| Feature / Agent | apex-class-decomposition-pattern | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
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 full fflib migration — see fflib-enterprise-patterns. NOT for trigger framework choice — see trigger-framework.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# 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
Related Skills
mfa-enforcement-patterns
Design MFA enforcement: auto-enablement, Salesforce Authenticator rollout, exceptions, service accounts, API-only users, SSO interop, and audit. Trigger keywords: MFA, multi-factor, two-factor, Salesforce Authenticator, MFA exception, MFA SSO, api-only MFA. Does NOT cover: end-user password policies, device-trust posture, or non-Salesforce IdP configuration.
encrypted-field-query-patterns
Design SOQL, filters, reporting, and indexes against Shield Platform Encryption fields. Trigger keywords: Shield Platform Encryption, encrypted field query, probabilistic vs deterministic encryption, encrypted SOQL filter, encrypted field index. Does NOT cover: Classic Encryption (deprecated), field-level security policy, or tenant secret key rotation.
data-classification-labels
Classify Salesforce fields by data sensitivity and compliance category using the four built-in classification attributes (SecurityClassification, ComplianceGroup, BusinessOwnerId, BusinessStatus). Covers Metadata API deployment, Tooling API querying, and Einstein Data Detect recommendations. NOT for data masking, Shield Platform Encryption, or runtime access control enforcement.
apex-managed-sharing-patterns
Grant row-level access programmatically via __Share records when declarative sharing rules cannot express the policy. NOT for OWD, role hierarchy, or criteria-based sharing rule design.
omnistudio-testing-patterns
Use when testing or validating OmniStudio components — OmniScript preview, Integration Procedure step debugging, DataRaptor field-mapping validation, and end-to-end UTAM-based automation. NOT for Apex unit testing or standard Flow debugging.
omnistudio-error-handling-patterns
Use when designing fault behavior across Integration Procedures, DataRaptors, OmniScripts, and FlexCards — error routing, user-facing messaging, retry semantics, and idempotency. Triggers: 'omnistudio error', 'integration procedure fault', 'dataraptor error handling', 'omniscript retry', 'flexcard action failure'. NOT for general Apex exception design or Flow fault paths.
omnistudio-ci-cd-patterns
Use when designing or implementing CI/CD pipelines for OmniStudio components — DataPack export/import, versioning, environment promotion, and automated deployment. NOT for standard Salesforce metadata CI/CD or Apex-only pipelines.
omniscript-design-patterns
Use when designing or reviewing OmniScripts for guided experiences, step structure, branching, save/resume, and the boundary between OmniScript, Integration Procedures, DataRaptors, and custom LWCs. Triggers: 'omniscript design', 'too many steps in omniscript', 'save and resume omniscript', 'branching in omniscript', 'when should this be an integration procedure'. NOT for deep Integration Procedure or DataRaptor design when the guided interaction layer is not the main concern.
integration-procedure-cacheable-patterns
Use when designing Integration Procedures (IPs) with platform cache to cut latency and callout load. Covers cache key design, TTL selection, per-user vs org-wide partitions, invalidation on data changes, and safe fallback on cache miss/stale. Does NOT cover general IP authoring (see omnistudio-error-handling-patterns) or LWC client-side caching.
flexcard-design-patterns
Use when designing, building, or reviewing OmniStudio FlexCards — including data source selection, card states, actions, conditional visibility, flyout configuration, and child card iteration. Triggers: 'FlexCard', 'card template', 'flyout', 'card action', 'card state', 'data source', 'child card', 'conditional visibility'. NOT for OmniScript design, standalone LWC development, or Apex controller architecture outside the FlexCard context.
dataraptor-patterns
Use when designing or reviewing OmniStudio DataRaptors, especially Extract versus Turbo Extract versus Transform versus Load, field mapping strategy, performance tradeoffs, and when to move work into Integration Procedures or Apex. Triggers: 'DataRaptor Extract', 'Turbo Extract', 'DataRaptor Load', 'DataRaptor Transform', 'OmniStudio data mapping'. NOT for overall OmniScript journey design or Integration Procedure sequencing when the main question is not the DataRaptor shape itself.
wire-service-patterns
Use when designing or reviewing Lightning Web Components that use `@wire`, Lightning Data Service, UI API, or the GraphQL wire adapter, especially for reactive parameters, cache behavior, and refresh strategy. Triggers: 'wire service', 'refreshApex', 'reactive parameter', 'getRecord', 'wire vs imperative Apex'. NOT for component communication or generic lifecycle issues when data provisioning is not the main concern.