Scaling Salesforce Automation Without Creating More Technical Debt
A two-person admin team that said yes to every small, reasonable automation request, and found four years later that nobody could safely change anything on the Contact object.
NonprofitBefore, eleven record-triggered flows ran on the Contact object across both save timings in an order nobody had chosen, none of them named to a convention, several with no entry criteria at all, and failures visible only to whoever happened to read the automation error emails. After, one flow per object per timing carries the logic, every flow name encodes object then timing then purpose, entry criteria state what changed rather than what the record looks like, and a fault path writes a visible exception record that appears on a queue somebody owns. The count fell, but the change that mattered was that the order and the failures both became things a person can read.
Before
- Eleven record-triggered flows on Contact
- Both save timings, order never set
- No naming convention, several ending in v2 or Final
- Four flows with no entry criteria
- Two subflows with no owner and no caller list
- Failures sent to an unread automation error address
Each flow was a reasonable answer to one request. Nothing here was a mistake at the time it was built.
After
- One record-triggered flow per object per timing
- Explicit ordering wherever a split was deliberate
- Names encoding object, then timing, then purpose
- Entry criteria stated as what changed
- One subflow, with a named owner and a stated contract
- A fault path that raises a visible exception record on an owned queue
The target state is not fewer flows. It is that every flow that exists was chosen, and every failure reaches a person.
A worked scenario showing how we approach this problem. The architecture and decisions are our real practice; it is not an account of one named customer.
The problem, as it actually presented
Nobody asks for help with technical debt. The request that arrives is a change that should take an hour and has taken three weeks, or a defect that two people have looked at and neither can explain.
In this shape the org is four years old and was set up well. Programme delivery, grant management and supporter fundraising all run on it. The admin team is two people, one of them part time, and between them they have said yes to a few hundred requests, almost all of which were small and almost all of which were reasonable.
That is how the Contact object ends up with eleven record-triggered flows. One sets a programme eligibility flag, one writes a household roll-up, one stamps a consent date, one notifies a programme lead. Each was the right answer to the question asked that week, and adding a new flow was always the lower-risk option, because opening the existing one meant understanding logic somebody else wrote for a reason nobody had recorded.
The compounding effects are not subtle once you look. Two of the eleven set the same field under conditions that overlap, so the winning value depends on which ran last. Four have no entry criteria at all, so they are evaluated on every save of a Contact, including the nightly integration load. Several are named for the ticket that produced them, so the flow list reads as a list of ticket numbers. Two subflows exist that nobody can name an owner for. And the automation failures go to a shared address that stopped being read after the person who set it up moved teams.
What we designed, and why
The instinct in the room was a rewrite. We argued against it, because the logic in these flows was mostly correct and had been quietly getting the right answer for years. Throwing it away would mean re-proving every business rule with a team that has no capacity to re-prove anything. The work is conventions applied to what exists, not a new estate.
The ordering is the risk, not the count
Where more than one automation runs on an object at the same timing, an execution order exists. The only question is whether it is one somebody chose.
Nobody had chosen this one. It had held for years, which is precisely what made it dangerous: the org had accumulated logic that depended on it, none of the dependencies were recorded, and the failure mode is a wrong value appearing after a release or a data load with nothing in the org having visibly changed.
So the design target is one record-triggered flow per object per timing, applied to the two busiest objects and left alone everywhere else. Where a split stays, it stays because somebody named a reason, and the ordering is written on both flows rather than inferred.
We also removed dependencies rather than recording them where we could. Logic that reads a value another automation has just written is fragile in a way that logic deriving the value itself is not.
Names that survive the person who wrote them
The flow list is the only navigable surface an admin has over the estate, and it is a list of names. Anything a name does not say has to be discovered by opening the flow.
The convention encodes what the flow acts on, then when it runs, then what it is for, in that order, because the list sorts alphabetically and clustering by object is what makes elimination possible. Ticket numbers came out. Version numbers came out, because the platform already versions flows and a name carrying Final describes build history rather than behaviour.
The convention goes in the build template and the definition of done, not in a review step. Reviews get skipped when a grant reporting deadline lands, and nobody goes back to rename afterwards.
Entry criteria, so a flow does not run on every save
Four flows had no entry criteria and instead opened with a decision element that routed most records straight to the end. That flow still ran. The transaction still paid for considering it.
Two changes fix the class. Conditions move into the entry criteria, which are evaluated before the flow is entered at all, rather than into the first decision element, which is evaluated after everything done to reach it. And conditions are stated as what changed rather than as what the record now looks like, because a condition describing current state fires on every save that leaves the record in that state, including the integration writes that have nothing to do with the process.
When a subflow is reuse, and when it is indirection
Two subflows existed. One was genuinely one thing: an address standardisation routine used by several processes, with a stable contract and an obvious business owner. That one stayed, and acquired the thing it had been missing, which is a named accountable role and a maintained list of callers.
The other was three similar things that had been merged because they looked alike, and had since grown branches for each caller. Every change to it required understanding all three callers from inside a component that could see none of them. We split it back into duplicated logic in the three places.
That reads as a regression and is not. Duplicated logic drifts, which is a visible and slow cost. An unowned shared component fails invisibly and everywhere at once, and a two-person team has no capacity to absorb the second failure mode.
Failures that reach a person
The fault paths were the smallest change and the one the team noticed most.
A create, an update or an action can fail for reasons the flow did not cause: a validation rule fires, a required field is blank on a record the flow is assembling, a service is unavailable. Without a fault path the flow stops where it stopped, and whatever was committed before that point stays committed. A parent updated and the child never created. A status advanced and the programme lead never notified.
So every element that can fail gets a fault path, and the fault path does two things. It writes an exception record carrying the record identifier, the flow, and what failed, onto a queue with an owner and a weekly review. And it leaves the data in a state somebody can reason about, which sometimes means reversing the earlier step and sometimes means flagging the record, and never means continuing silently.
An email address nobody reads is not error handling. It is the appearance of error handling, which is worse, because it stops anybody asking the question again.
Implementation
The sequence is chosen so that a two-person team can stop after any stage and still be better off.
Audit first, and make it a list rather than a document. Every record-triggered flow by object and timing. Wherever two appear at the same timing you have an ordering dependency nobody chose, and that list is the work queue in priority order.
Then the naming pass. It changes no behaviour, which makes it the safest work in the programme and the one that makes everything after it cheaper. Rename, and write the four description lines while you are in there: why it exists and the ticket or policy behind it, the accountable role, what it acts on and explicitly what it does not, and what else runs on the same object at the same timing.
Then consolidation, on the two busiest objects only. Not the whole org. The objects where the ordering risk is real, one at a time, with the fixtures from the audit used to confirm behaviour did not change.
Then entry criteria, then fault paths. Fault paths last because they are the one stage that adds new records and a new review habit, and a habit introduced during a migration does not survive it.
Configuration that the business changes, such as programme eligibility thresholds, moved into custom metadata rather than staying hardcoded in decision elements. That single change converts a class of request from a flow modification into a data edit, which for a two-person team is the difference between a deployment and a Tuesday.
The decisions that were contested
Not rewriting. There was appetite for a clean rebuild and it was the more satisfying option. We argued against it on capacity grounds: a rebuild means re-establishing every business rule with the two people who are also running the ticket queue, and the rules were not the problem. The order, the names and the silence were.
Splitting a subflow back into duplicated logic. This was the hardest conversation, because it looks like undoing good practice. The position we held is that reuse without an owner is not reuse, and the team could not name an owner for that component or reconstruct its callers without opening every flow in the org.
Leaving nine objects alone. The tidier programme applies the convention everywhere. We scoped it to the two objects where the ordering risk was real and left the rest with a naming pass only, because a convention adopted on two objects and held is worth more than one adopted on eleven and abandoned in March.
Booking deletions rather than deactivating. Three flows were switched off years ago by people who have left. Deactivating again would have added to the graveyard. Each got a stated window, a named watcher and a booked deletion date, which turns an argument about risk into an experiment with a deadline.
What changes
The outcomes worth claiming are operational, and they follow from the conventions rather than from effort.
| Before | After | What made the difference |
|---|---|---|
| Eleven flows on one object, order never chosen | One flow per object per timing, splits documented with explicit order | The dependency became something somebody decided rather than inherited |
| Flow names carrying ticket numbers and Final | Names encoding object, timing and purpose | The list became searchable by elimination, so most flows never get opened |
| Four flows evaluated on every save | Conditions in the entry criteria, stated as what changed | Evaluation moved ahead of entry rather than after it |
| Two subflows, neither with an owner | One subflow with a named role and a caller list | Reuse now has an accountable owner, and the rest is visible duplication |
| Failures sent to an unread address | A fault path raising an exception record on an owned queue | A failure now produces something with a record identifier on it |
| Eligibility thresholds hardcoded in decisions | Thresholds in custom metadata | A business change stopped being a deployment |
The second-order effect is the one the team noticed. Change requests started being estimated as changes again, because the question of what else touches this field now has an answer that can be read out of the org rather than reconstructed.
What we would tell you before starting
Start with the audit and resist starting with the rewrite. The list of objects carrying more than one flow at the same timing takes an afternoon to produce and it will reorder your priorities, because the objects that feel worst are rarely the objects where the ordering risk is real.
Scope it smaller than feels right. A convention held on two objects compounds. A convention announced across the org and abandoned when the next deadline lands teaches the team that conventions do not survive contact with real work, and that lesson is expensive to unteach.
And be honest about the constraint. With two people, the maintainable option beats the clever one every time, and that is a design principle rather than an apology. Duplicated logic somebody can read, a flow named so it can be eliminated from a search, and a failure that lands on a queue are all worth more than an elegant estate that only works while the person who built it is still answering their phone.
