In one of the projects I worked on recently, I dealt with files of more than 2 thousand lines that nobody actually knew what they did. The level was such that I used to joke with the team: it seemed that just removing a System.debug (a line that changes absolutely nothing in the behavior) would break something somewhere else in the org. That was the size of the fear of touching that code.
The conclusion I reached is that it doesn't matter the project: the pattern is always the same, there is no pattern at all. At some point someone creates a controller that does everything at once. In another, it's a Service writing a query directly. The result is always predictable: code that nobody understands and that nobody can maintain.
It's not a lack of talent. Whoever developed it understands business, understands Salesforce. The problem is that nobody established a shared vocabulary. What is a Service? What is a DAO? Where does the business rule go? Without this agreed-upon answer, each developer does it the way they think is best, the org grows, and after two years nobody changes anything without fear of breaking three other processes.
In the real world we don't always have time for perfect code, but defining design patterns already gets the work 90% on track. This post is the guide I apply when I enter a new org.
The problem starts at the trigger
The trigger has only one job: detect the event and delegate. That's it.
In practice, this is what I find:
trigger AccountTrigger on Account (before insert, after insert, before update, after update) {
if (Trigger.isAfter && Trigger.isInsert) {
List<Task> tasks = new List<Task>();
for (Account acc : Trigger.new) {
// SOQL inside a loop: governor limit overflow guaranteed at volume
List<Contact> contacts = [
SELECT Id, Email FROM Contact WHERE AccountId = :acc.Id
];
if (acc.Industry == 'Technology') {
Task t = new Task();
t.Subject = 'Onboarding Tech';
t.WhatId = acc.Id;
// 20 more lines of logic here
tasks.add(t);
}
}
insert tasks;
// external API call here too
for (Account acc : Trigger.new) {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.external.com/accounts');
// ...
}
}
if (Trigger.isBefore && Trigger.isUpdate) {
// 100 more lines...
}
}This trigger does four different things. When the onboarding rule changes, you touch here. When the external API changes, you touch here. When you want to test the Task logic in isolation, you can't: it's tied to the trigger context.
Trigger Handler: the first step
The simplest pattern that already eliminates 80% of the chaos. The trigger calls a handler, and the handler delegates to whoever knows how to solve it.
// AccountTrigger.trigger
trigger AccountTrigger on Account (before insert, after insert, before update, after update) {
AccountTriggerHandler handler = new AccountTriggerHandler();
if (Trigger.isBefore && Trigger.isInsert) handler.beforeInsert(Trigger.new);
if (Trigger.isAfter && Trigger.isInsert) handler.afterInsert(Trigger.new);
if (Trigger.isBefore && Trigger.isUpdate) handler.beforeUpdate(Trigger.new, Trigger.oldMap);
if (Trigger.isAfter && Trigger.isUpdate) handler.afterUpdate(Trigger.new, Trigger.oldMap);
}// AccountTriggerHandler.cls
public class AccountTriggerHandler {
public void afterInsert(List<Account> newAccounts) {
AccountService.processNewAccounts(newAccounts);
}
public void beforeUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
AccountService.validateChanges(newAccounts, oldMap);
}
}The trigger became three lines per event. The handler is a router: it has no logic, it just knows who to call.
Service: where the business logic lives
The Service is the class that knows the business rules. It doesn't know about triggers, doesn't know about HTTP, doesn't write SOQL. It receives data, applies rules and calls whoever it needs to persist or communicate.
public class AccountService {
public static void processNewAccounts(List<Account> accounts) {
List<Account> techAccounts = new List<Account>();
for (Account account : accounts) {
if (account.Industry == 'Technology') {
techAccounts.add(account);
}
}
if (!techAccounts.isEmpty()) {
List<Task> tasks = createOnboardingTasks(techAccounts);
insert tasks;
ExternalIntegrationService.notifyNewAccounts(techAccounts);
}
}
private static List<Task> createOnboardingTasks(List<Account> accounts) {
List<Task> tasks = new List<Task>();
for (Account account : accounts) {
tasks.add(new Task(
Subject = 'Onboarding Tech — ' + account.Name,
WhatId = account.Id,
ActivityDate = Date.today().addDays(3)
));
}
return tasks;
}
}Notice what the Service does not do: it has no SOQL, no HttpRequest, no trigger logic. When the onboarding rule changes (the deadline, the subject, the condition), you change here and only here.
DAO: who writes SOQL
In every messy org, the same SOQL appears in four different places, sometimes with different fields, sometimes without a field someone needed. When a field changes name, you hunt in every place.
The DAO (Data Access Object) centralizes the queries. It's the only class that writes SOQL for a given object.
public class AccountDAO {
public static List<Account> getByIds(Set<Id> ids) {
return [
SELECT Id, Name, Industry, OwnerId, Active__c
FROM Account
WHERE Id IN :ids
];
}
public static List<Account> getTechWithContacts(Set<Id> ids) {
return [
SELECT Id, Name, Industry,
(SELECT Id, Name, Email FROM Contacts WHERE HasOptedOutOfEmail = false)
FROM Account
WHERE Id IN :ids
AND Industry = 'Technology'
];
}
public static Integer countContacts(Id accountId) {
return [SELECT COUNT() FROM Contact WHERE AccountId = :accountId];
}
}Now the Service doesn't need to know SOQL, it just calls the DAO:
public static void processChanges(List<Account> accounts, Map<Id, Account> oldMap) {
Set<Id> changedIds = new Set<Id>();
for (Account account : accounts) {
if (account.Industry != oldMap.get(account.Id).Industry) {
changedIds.add(account.Id);
}
}
List<Account> accountsWithContacts = AccountDAO.getTechWithContacts(changedIds);
// applies logic...
}Controller: the LWC's entry point
The Controller is the bridge between the front (LWC) and the back (Apex). And its job is exactly that, and nothing beyond it: expose @AuraEnabled methods and forward the call to the Service. No business rule, no SOQL, no assembling data.
public class AccountController {
@AuraEnabled(cacheable=true)
public static AccountDetailWrapper getDetail(Id accountId) {
return AccountService.buildDetail(accountId);
}
@AuraEnabled
public static void activateAccount(Id accountId) {
AccountService.activate(accountId);
}
}If you take a look at a Controller and it has a for, a SOQL or an if with a business rule, someone put logic in the wrong place. The Controller is thin on purpose: the day you swap the LWC for a REST integration, the rule remains intact in the Service.
Wrapper: your shield against the external world
Two cases where the Wrapper appears in the Salesforce ecosystem.
Case 1: data for LWC. When a component needs fields from different objects, or calculated fields that don't exist in the database, the Wrapper groups everything into a single structure.
public class AccountDetailWrapper {
@AuraEnabled public Account record { get; set; }
@AuraEnabled public Integer totalContacts { get; set; }
@AuraEnabled public String statusLabel { get; set; }
@AuraEnabled public Boolean canInvoice { get; set; }
public AccountDetailWrapper(Account acc, Integer totalContacts) {
this.record = acc;
this.totalContacts = totalContacts;
this.statusLabel = acc.Active__c == true ? 'Active' : 'Inactive';
this.canInvoice = acc.Active__c == true && totalContacts > 0;
}
}Notice how it connects with the Controller we saw just now: that getDetail method has exactly this Wrapper as its return type. The Controller exposes, the Service builds the new AccountDetailWrapper(...) using the DAO to fetch the data:
// inside AccountService
public static AccountDetailWrapper buildDetail(Id accountId) {
Account acc = AccountDAO.getByIds(new Set<Id>{ accountId })[0];
Integer total = AccountDAO.countContacts(accountId);
return new AccountDetailWrapper(acc, total);
}In the LWC component, you receive a clean object with everything you need:
// accountDetail.js
import getDetail from '@salesforce/apex/AccountController.getDetail';
@wire(getDetail, { accountId: '$recordId' })
wiredAccount({ data, error }) {
if (data) {
this.statusLabel = data.statusLabel;
this.canInvoice = data.canInvoice;
}
}Case 2: external API response. When you consume an external service, the Wrapper maps the JSON response to a typed Apex class. You don't pass String throughout the code.
public class ExternalIntegrationWrapper {
public String transactionId { get; set; }
public String status { get; set; }
public Decimal amount { get; set; }
public String error { get; set; }
public static ExternalIntegrationWrapper fromJson(String responseBody) {
return (ExternalIntegrationWrapper) JSON.deserialize(
responseBody, ExternalIntegrationWrapper.class
);
}
public Boolean success() {
return status == 'APPROVED' && String.isBlank(error);
}
}Now, when the API changes the response format, you change the Wrapper and only the Wrapper.
How it all fits together
Trigger LWC
└── TriggerHandler └── Controller (exposes @AuraEnabled)
└── Service ◄───────────────────┘
├── DAO (SOQL queries)
├── Wrapper (data structure)
└── *Service (other domains or integrations)
The LWC calls an Apex Controller, the Controller calls the Service, and the Service calls the DAO and the Wrapper. The trigger enters through the same point: TriggerHandler calls the Service. Notice that the Service is the center of everything: both the trigger and the Controller converge to it.
The rule that sums it all up: each class has one reason to change. The trigger changes when the event changes. The handler changes when the event mapping changes. The Controller changes when the contract with the LWC changes. The Service changes when the business rule changes. The DAO changes when the fields or conditions of the query change. The Wrapper changes when the data structure that the LWC needs changes.
If you can list more than one reason to edit a class, it's doing too much.
Where to start if the org already exists
Don't try to refactor everything at once. This creates conflict, increases the risk of regression and rarely gets approved.
What works: on the next feature you're going to implement, apply the patterns to it. Create the Service, the DAO, the Wrapper for that specific feature. Over time the pattern spreads, and when someone needs to touch the old code, they'll see the new pattern alongside and follow it.
Consistency comes from convention, not from mass refactoring.
I work with Salesforce architecture and development for companies that want a sustainable org, without the fear of breaking everything with every change. If this describes your scenario, take a look at what I do at arthurmenezes.dev.