Domain Layer Patterns in Apex That Survive Real Projects
The worst Apex class I have ever had to modify was called OpportunityService. It was a little over 3,000 lines, and it did everything — validation, defaulting, discount maths, three integration callouts, and a chunk of reporting logic that had no business being anywhere near it. When I asked the team why every scrap of Opportunity behaviour lived in one enormous file, I got the answer you always get. It started small.
That class is what the domain layer exists to prevent. Not with more discipline or stricter code review, but with a structural home for the behaviour that belongs to an object. This is the pattern I reach for on any org large enough to justify it, and I want to walk through what actually survives contact with a real project — including the parts of fflib I have stopped bothering with.
The Service Class That Ate the Opportunity Object
Before the pattern, the problem. On that org I counted the Opportunity validation logic once, out of morbid curiosity. There were 41 separate places where Amount was being checked — spread across the service class, a couple of triggers, three Process Builder flows, and two validation rules that contradicted each other depending on record type. A developer named Ravi had inherited most of it and was too scared to delete any of it, because nobody could tell him which paths were live.
The domain layer is Martin Fowler's domain model, ported to Salesforce by the fflib Apex Enterprise Patterns library. The idea is narrow and useful: a domain class holds the behaviour and rules that belong to one SObject. Validation for Opportunity goes in Opportunities. Defaulting for Account goes in Accounts. When you want to know what an Opportunity is and is not allowed to do, there is exactly one file to open.
What Actually Belongs in a Domain Class
This is where most people get it wrong, so I want to be precise. The domain layer is for behaviour that is intrinsic to the record — the rules that would be true no matter who or what was changing it.
That means:
- Validation. "Amount must be positive." "You cannot close an Opportunity with open tasks."
- Defaulting. "New Opportunities in the Enterprise segment start at Stage
Qualification." - State transitions. "An Opportunity can move from
NegotiationtoClosed Wonbut not back." - Derived field maintenance. Rolling a discount into the amount, stamping a formatted name.
It does not mean orchestration across objects, integration callouts, or anything that queries the database. That is the single most common mistake I see — people treat the domain class as the new god object and pour service logic straight back into it. If a method needs to coordinate three objects and call an external system, that is a service, and the service asks the domain to do its part. The domain does not reach out. Things are handed to it.
The Base Class, and How It Plugs Into Your Triggers
A domain class extends fflib_SObjectDomain and overrides the hooks it cares about. The base class gives you a lifecycle that maps onto trigger contexts, so you are never reading Trigger.isBefore inside business logic again.
public inherited sharing class Opportunities extends fflib_SObjectDomain {
public Opportunities(List<Opportunity> records) {
super(records);
}
public override void onApplyDefaults() {
for (Opportunity opp : (List<Opportunity>) Records) {
if (opp.StageName == null) {
opp.StageName = 'Prospecting';
}
}
}
public override void onValidate() {
for (Opportunity opp : (List<Opportunity>) Records) {
if (opp.Amount == null || opp.Amount <= 0) {
opp.Amount.addError('Amount must be greater than zero');
}
}
}
public override void onValidate(Map<Id, SObject> existing) {
for (Opportunity opp : (List<Opportunity>) Records) {
Opportunity old = (Opportunity) existing.get(opp.Id);
if (old.StageName == 'Closed Won' && opp.StageName != 'Closed Won') {
opp.addError('A won Opportunity cannot be reopened');
}
}
}
public class Constructor implements fflib_SObjectDomain.IConstructable {
public fflib_SObjectDomain construct(List<SObject> records) {
return new Opportunities(records);
}
}
}
onApplyDefaults runs on before insert, onValidate() on after insert, and the onValidate(Map) overload on after update with the old values handed to you. The inner Constructor class is fflib boilerplate — it exists so the trigger can build the right domain type without knowing which one it is.
The trigger itself is five lines and does nothing but delegate. In the framework I usually bring to an engagement, every object gets exactly one trigger that hands off to a handler, and I have written before about why that discipline matters more than it looks. The domain class is what that handler delegates to for the object's own rules.
trigger OpportunityTrigger on Opportunity (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
fflib_SObjectDomain.triggerHandler(Opportunities.class);
}
One detail worth knowing: fflib_SObjectDomain checks object-level CRUD before it runs the trigger, by default. That dovetails neatly with how I think about CRUD and FLS enforcement in Apex — you get object security for free at the domain boundary, and you layer field-level checks on top. You can switch the CRUD check off, and there are legitimate reasons to, but do it deliberately rather than because a test failed and you wanted it green.
Behaviour Goes In, Queries Stay Out
The rule I enforce hardest: a domain method never runs a query. Not one SOQL statement. If a domain method needs related records, they get passed in as an argument. The query patterns I lean on to stay under governor limits live in the selector layer, not the domain, and keeping that boundary clean is what stops the domain class turning back into the 3,000-line monster I started with.
Behaviour methods take the work they need and register their changes with a Unit of Work, rather than committing DML themselves.
public void applyDiscount(Decimal percentage, fflib_ISObjectUnitOfWork uow) {
for (Opportunity opp : (List<Opportunity>) Records) {
if (opp.Amount != null) {
opp.Amount = opp.Amount * (1 - percentage / 100);
uow.registerDirty(opp);
}
}
}
The method changes the records in memory and tells the Unit of Work they are dirty. It does not call update. The service that owns the transaction decides when to commit. This is what makes domain methods composable — you can call three of them in one transaction and they share a single set of DML operations at the end, instead of each one saving independently and blowing the DML statement limit.
Validation Has Exactly One Home
This is the payoff, and it is worth stating as an opinion rather than a suggestion. Every rule that governs whether a record is valid belongs in the domain class, and nowhere else. Not in a validation rule, not in a Flow, not in the LWC that happens to create the record.
The reasoning is about surface area. On the org with 41 places checking Amount, the actual bug was that the API create path skipped two of the checks the UI enforced, so an integration was quietly loading Opportunities that the sales team could never have saved by hand. When validation lives in the domain, it runs on every path into the database — UI, API, Apex, Bulk API, Flow — because it runs in the trigger. There is no side door.
I will be honest about the compromise here. Declarative validation rules give admins something they can edit without a deployment, and that has real value on a team where the admins outnumber the developers. So I do not ban validation rules outright. I keep the ones that are genuinely simple and unlikely to change — required fields, basic format checks — as validation rules, and everything with logic or cross-object awareness goes in the domain. The line is fuzzy and I redraw it per org.
A Factory So You Never Call the Constructor Directly
The last piece that makes this maintainable is not calling new Opportunities(...) all over the codebase. You go through a factory, registered once in an application class.
public class Application {
public static final fflib_Application.DomainFactory Domain =
new fflib_Application.DomainFactory(
Application.Selector,
new Map<SObjectType, Type> {
Opportunity.SObjectType => Opportunities.Constructor.class
}
);
}
Callers then ask the factory for a domain instance, either from records they already hold or from a set of Ids:
Opportunities opps =
(Opportunities) Application.Domain.newInstance(oppList);
opps.applyDiscount(10, uow);
The reason this matters is testing. Because everything routes through the factory, a unit test can register a mock domain in its place and never touch the database. That is the difference between a suite that runs in seconds and one that spins up hundreds of records per method, and on a large org it is the difference between developers who run the tests and developers who avoid them.
When I Don't Bother
Here is the part the fflib evangelists skip. The full enterprise pattern stack — domain, selector, service, Unit of Work, application factory — is a lot of scaffolding, and on a small org it is scaffolding around an empty room.
If I am building a five-object org for a 30-person business with two Apex classes total, I do not stand up the whole apparatus. I write a plain trigger handler, put the validation in one method, and get on with it. The domain layer earns its keep when you have real behavioural complexity, multiple developers, or a managed package where the structure is a contract between teams. Below that threshold it is ceremony, and ceremony that a junior admin cannot read is a liability, not an asset.
What I would tell my earlier self is to introduce it gradually. You do not need the factory and the Unit of Work on day one. Start with a domain class that just holds validation, plugged into your existing trigger handler. Add the selector when queries start duplicating. Add the Unit of Work when DML gets tangled. I have seen more teams abandon fflib because someone dropped the entire framework on them at once than because the pattern was wrong. If you have a service class quietly growing towards 3,000 lines and you want a second pair of eyes before it gets worse, that is the kind of review I do.
The pattern is not clever, and it is not meant to be. Its whole value is that six months later, when someone asks where the Opportunity rules live, the answer is a filename instead of a shrug — and Ravi can finally delete the code he was afraid to touch.
Liked this?
New essays in your inbox when I publish. No cadence, no spam.
You might also like
Record-Triggered Flows vs Apex Triggers: The Decision Framework
Record-triggered flows or Apex? I've built both in production for years. Here's the exact framework I use to decide, and where each one quietly falls apart.
Read →Custom Metadata Types: The Swiss Army Knife You're Underusing
Most admins use custom metadata for one lookup table and stop. Here's how I use it to kill hardcoded config, dodge governor limits, and keep rules in source.
Read →When to Use Platform Events vs Change Data Capture
Platform Events and Change Data Capture look similar but solve different problems. Here's how I decide between them on real Salesforce integration work.
Read →