I am convinced, that multiple triggers per object are a good practice. I fully disagree with the "one trigger per object" concept and believe, that it is outdated.
The paradigm originated way before 2nd generation packaging (SFDX). Back then, developers had one big unstructured pile of source (or were still developing on sandboxes ...). The considerations that were valid back then, do not apply anymore.
TL;DR
These are the reasons, why it is totally fine to have multiple triggers on the same object in an org:
- You have multiple 2nd generation packages that implement their own domain layer (on the same objects).
- Trigger frameworks introduce too much complexity while solving too few problems. They make maintenance harder, not easier.
- You want to keep your triggers clean, but still want to execute certain logic only in certain contexts.
Thoughts About 2nd Generation Packages
The introduction of 2nd generation packaging made the "one trigger" paradigm obsolete. Your packages —by design— should not know about each other or declare a dependency hierarchy otherwise. In both cases, they cannot access or modify contents of the other package.
Without a heavy investment in a trigger framework (that both packages share as a dependency) it is technically not possible to have one trigger on the same object that both packages use.
The benefits of separating your code into packages (scaling development teams, independent deployments, lower complexity in projects, improved dependency management, etc) by far outweigh the negligible downsides of multiple triggers. You shouldn't have to worry about order of execution for functionalities that shouldn't depend on each other in the first place.
Until today, I did not find a framework out there that actually reduced complexity. All frameworks (including the ones I came up with) made trigger development much more tedious, introduced bizarre-to-implement interfaces, were hard to debug and less performant.
So to wrap it up:
- If your packages/applications have domain logic on the same object, it is totally fine to implement multiple triggers per package.
- Do not bother to introduce a framework. They all come with too much overhead for too little benefits.
- It is much easier to design your domain logic so you don't rely on order of execution accross packages.
- Treat your packages as independent applications: They should declare conscise "APIs" (if they are used as a dependency), advance with semantic versioning and should be independently deployable.
So you are totally fine, if you have one trigger per object per package. But not one per org any more (which was the original "best practice").
Thoughts About Reducing Complexity
Besides the package-based considerations, I like to keep the complexity of an application as minimal as possible. I design everything around the concept that the best code is code, that is never written. Less code means easier onboarding for new developers and less edge cases to handle.
I believe, that trigger frameworks and complex single-object triggers are diametrically opposed to this best practice. I see a very common misunderstanding within the developer community: A framework or library is not justified by its bare existence. It has to make a certain task so much easier (usually by abstraction or smart concepts), that the usage outweighs the additional complexity (understanding it, maintaining it, fixing it, relying on a third party, etc.)
Why I Don't Like Single-Object Triggers
Take this generic implementation that follows the "one trigger" paradigm, for example:
trigger onMyCustomObject on MyCustomObject__c(before insert, after insert, before update, after update, before delete, after delete, after undelete) {
if (Trigger.isBefore) {
if (Trigger.isInsert) {
SomeHandler.specialBeforeInsertStuff(Trigger.new);
}
if (Trigger.isInsert || Trigger.isUpdate) {
SomeHandler.functionalityOne(Trigger.new);
SomeOtherHandler.functionalityTwo(Trigger.new, Trigger.oldMap);
}
if (Trigger.isUndelete) {
SomeHandler.functionalityOne(Trigger.new);
}
} else {
if (Trigger.isInsert) {
SomeHandler.afterInsertUpdateStuff(Trigger.new);
}
if (Trigger.isDelete) {
SomeHandler.calculateSomethingElse(Trigger.old);
}
}
}
The trigger executes on every DML statement, just to run logic to stop executing. This is completely unnecessary.
- At least the context is solved within the trigger and not delegated to another class/framework.
- Readability and overhead are manageable, but I think this approach lacks maintainability. It is not intuitive to see, what logic is executed in the
insert
context.
- Adding new logic (e.g. adding a new execution context, if the trigger not already runs on every single context) may cause unintended execution of other handlers.
If you do not start with "all contexts", you will find yourself continuously adding contexts and if-else statements until you end up with a trigger like that.
Why I don't Like Trigger Frameworks
I evaluated a lot of frameworks (fflib, Dan Appleman's proposal, and virtually all proposals you can google) and found one common denominator among all of them: They introduce a lot of complexity and overhead, are hard to understand and basically solve no problem at all.
Let me emphasize: Order of execution shouldn't be a problem for you, if your domain layer is well designed. Solving design flaws in your code with massive overhead and complexity is an anti-pattern.
The frameworks I found can be divided in roughly two categories:
- Custom metadata based frameworks that dynamically construct handlers based on a registry (these are compatible with 2nd generation packaging)
- Monolithic "know it all" handlers that register all handlers at compile time in their "execute" method (these are not compatible with 2nd generation packaging)
Take this common "dynamic approach", for example:
trigger onMyCustomObject on MyCustomObject__c(before insert, after insert, before update, after update, before delete, after delete, after undelete) {
MyTriggerFramework.execute(MyCustomObject__c.getSObjectType());
}
The framework itself is basically untestable. It makes heavy use of static Trigger.*
variables to understand the context it is running in, constructing it's handlers with Type.forName
and executing their onBeforeInsert
, onAfterUpdate
, etc methods).
- The trigger must execute in all contexts, even though we might only have one handler registered for a specific context.
- The framework must implement triggers on all standard objects (so packages that use the framework as a dependency can make use of it)
- The framework does not know about custom objects, so the dependent packages must implement the "framework trigger" within their domain.
- If something does not work as expected, you spend more time debugging if your handler ran in the wrong context, than actually debugging the implementation.
- Debug logs are completely cluttered. Every DML statement (including your test fixtures) always executes all triggers/handlers, just to run through a bunch of if-else statements to determine, if the handler should be called or not.
A framework must therefore know about its dependents, which effectively breaks the idea of isolation.
This is a couple of hundred lines of debug log lines, just to understand that your code didn't even execute. Also, this code is slow. I frequently deal with debug logs that show idle/wait times of hundreds of ms until custom metadata is initialised (while SOQL gets executed in a tenth of the time ...). And as mentioned above: Registering those handlers to execute in a package-based environment is tedious to say the least.
Since monolithic frameworks are not compatible with second generation packaging it makes no sense to investigate them.
Taking It Further: One Trigger Per Context
What I find way more intuitive and much easier to scale is the following approach:
trigger onMyCustomObjectDelete on MyCustomObject__c(after delete) {
MyCustomObjectDomain.doSomethingMeaningful1(Trigger.old);
MyCustomObjectDomain.doSomethingEvenMoreImportant(Trigger.old);
}
trigger onMyCustomObjectInsert on MyCustomObject__c(before insert, after insert) {
if (Trigger.isBefore) {
MyCustomObjectDomain.fillMyFieldsFromSomething(Trigger.new);
MyCustomObjectDomain.calculateFormulaFields(Trigger.new);
} else {
MyCustomObjectDomain.insertRelatedRecords(Trigger.new);
}
}
trigger onMyCustomObjectUpdate on MyCustomObject__c(before update, after update) {
if (Trigger.isBefore) {
MyCustomObjectDomain.fillMyFieldsFromSomething(Trigger.new);
}
}
Yes -- you end up with up to 4 triggers per object. But only, if you need to execute logic on all those contexts.
In my opinion, the biggest advantage is the readability of code and debug logs as well as the testability of the code.
- The implementation is usually very straight-forward and very easy to understand.
- Salesforce handles the DML context for you. You don't have to. Your code only get's executed when it SHOULD, instead of executing always and then implementing a bunch of logic to stop the execution.
- As a developer, I care to understand what happens when an object is inserted, updated or deleted. This allows me to see everything related to a particular transaction context in one file.
- The code is very easy to extend without side effects. Need additional logic that is executed in
before insert
and before update
context? Write the domain method and hook it into both triggers. No need to add additional if/else statements in the "single big trigger" to make sure existing code does not run in the new context (or, even worse, implement a handler with 7 interface methods where 5 methods do nothing and register it somewhere ...)
I know this might be a very controversial proposal. I would love to hear the communities thoughts about that.