GTM Systems
Someone Wrote 200 Lines of Apex to Copy One Field, and It Failed a Deploy Two Years Later
The declarative-first rule is not dogma; it is a bet on who can maintain the thing after you leave. Here is the rule, the specific cases that genuinely need Apex, and the honest line between them.
· 8 min read
I once spent an afternoon deleting 200 lines of Apex whose entire job was to copy a field from the Account to the Contact on insert. It had a trigger, a handler class, a test class, and a mocking utility, and it failed a validation deploy two years after the author left because a schema change broke the test and nobody understood the code well enough to fix it. The whole thing was a before-save flow’s worth of work wearing a full software-engineering costume. The person who wrote it was a strong developer who reached for the tool they knew. The org paid for that reach every deploy after they were gone.
That is the case for declarative-first, and it is not a purity argument. It is a bet about maintenance. A flow can be opened, read, and edited by the admin who inherits the org; Apex requires a developer, a test class, code coverage, and a deploy pipeline. When the declarative tool can do the job, using code means you have made the thing harder to maintain in exchange for nothing. The rule is: build it declaratively unless you hit a wall the platform genuinely cannot clear, and know exactly where those walls are so you are not guessing.
The rule is a maintenance bet, not a taste
The reason to prefer declarative is that the population who can safely change a flow is far larger than the population who can safely change Apex. Every Apex class you add is a class someone must understand, test, and keep at 75 percent coverage to deploy anything in that org ever again. A broken test class from a departed developer can block an unrelated deploy for the whole team, which is exactly how the field-copy trigger held a release hostage. Flows have their own failure modes, but “an admin cannot read it” is not one of them. When you choose code over config for work config can do, you are trading away maintainability for familiarity, and the org pays the interest.
View as table
| Item | Value |
|---|---|
| Point-and-click config | 90% |
| A record-triggered flow | 70% |
| A well-written Apex class | 25% |
| Inherited undocumented Apex | 8% |
Where the declarative wall actually is
Declarative-first does not mean declarative-only. There are cases the platform genuinely cannot clear with config, and pretending otherwise produces flows that are worse than the Apex they replaced: a monstrous flow with forty elements doing loops-within-loops that no admin can read either. Know the real walls. Complex bulk logic across many records where you need fine control over DML and governor limits. True recursion or multi-level tree traversal (walking an account hierarchy to the root). Callouts that need custom retry, chaining, or response parsing beyond what the HTTP action offers. Operations that must run as a single atomic transaction with rollback semantics a flow cannot express. And anything genuinely reusable across many contexts where an invocable Apex method called from flows beats copying flow logic five times.
When you do write Apex, write the piece config cannot do
The best pattern is not all-flow or all-Apex; it is a flow that orchestrates and an invocable Apex method that does the one hard thing. The admin owns the flow, the criteria, and the ordering; the developer owns the narrow, well-tested method the flow calls. The field-copy trigger should have been a flow. But an account-hierarchy roll-up that has to walk to the root and sum across every descendant is a genuine wall, and the right shape is an invocable the flow hands the account to.
// The narrow piece config cannot do: walk an account tree to the root and
// sum. Exposed as invocable so a flow orchestrates and an admin owns the
// trigger criteria. The developer owns only this one hard method.
public with sharing class HierarchyRollup {
@InvocableMethod(label='Roll up ARR to top parent')
public static List<Result> run(List<Id> accountIds) {
// Recursive CTE-style walk is not expressible in Flow; this is a real wall.
// ... traverse ParentId to root, aggregate child ARR, return top-parent totals
}
public class Result { @InvocableVariable public Id topParentId; @InvocableVariable public Decimal totalArr; }
}
The worked example: the field copy, both ways
Reaching for code versus config-first with a clear line
| Code by default | Declarative-first, code past the wall | |
|---|---|---|
| A simple field copy | 200 lines + test class | A four-element flow |
| Who can maintain it | A developer, if they decode it | Any admin who can read a flow |
| Deploy risk | One stale test blocks all deploys | No coverage debt to carry |
| A hierarchy roll-up | Apex (correct: real wall) | Apex (correct: real wall) |
| When to write code | Whenever it feels faster | Only when config genuinely cannot |
| Org two years later | A pile of undocumented classes | Flows an admin owns, code where it earns it |
Here is how I decide declarative versus code
- 1
Start by assuming a flow can do it
Field updates, defaults, cross-object writes, record-triggered automation, standard callouts, most approvals. The default answer is config. Make code prove it is needed, not the other way around.
- 2
Name the specific capability config lacks
Finish "the platform cannot ___" with a real capability: recursion, atomic rollback, limit control on large volumes, custom retry. If you cannot name one, there is no wall and no reason for Apex.
- 3
If there is a wall, isolate the hard part
Write an invocable Apex method that does only the thing config cannot, and let a flow orchestrate around it. The developer owns a narrow, tested method; the admin owns the criteria and ordering.
- 4
Keep the flow readable or you have not won
A forty-element flow with nested loops is as unmaintainable as the Apex it replaced. If config forces a monster, that is itself a signal you may be past the wall.
- 5
Test the invocable like real code
The narrow Apex piece still needs a real test asserting behavior. You reduced the code surface; you did not remove the obligation to prove the part that remains works.
- 6
Delete the code that config made obsolete
When you find a 200-line trigger doing a flow-sized job, replacing it is not scope creep; it is paying down debt that will block your next deploy. Prove behavior parity, then delete.
The 200-line field copy was not written by a bad engineer. It was written by a good one who reached for the tool in their hand instead of the tool the org could maintain. Declarative-first is a bet that the person inheriting your org can read a flow more easily than they can decode a trigger, and that bet pays off every deploy after you are gone. Build it in config by default, name the specific wall when you hit one, and write Apex only for the narrow piece the platform genuinely cannot express. The reward is an org where the automation outlives its authors, which is the same goal behind documenting the firing order and paying down the automation debt an accreted stack leaves behind.
Keep reading
One email. Every week.
One email a week: a systems problem I architected or untangled, with the schema, the config, and what I would change. No roundups, no theory, unsubscribe whenever it stops being useful.
The newsletter opens soon.
Connect a provider in src/config.ts