GTM Engineering
Your Stage Logic Lives in Seven Places and No Two Agree
Everyone puts stage exit criteria on a slide, then scatters the enforcement across validation rules, flows, and Apex that drift apart. Here is the same governance as one metadata type, a generic service class, and a test that proves the gate holds.
· 14 min read
Four validation rules, two flows, and one Apex trigger, and no two of them agreed. That is the stage-gate logic I inherited in one org, seven artifacts enforcing the same policy in seven slightly different ways. For a decade the default was to write stage logic wherever it was convenient: a formula here, a flow there, an Apex check when the flow could not do it. Every RevOps deck has the tidy exit-criteria grid, “an opp cannot reach Proposal without a confirmed economic buyer and a close date,” and then you open the org and the rule lives nowhere you can point to. It is a Confluence page reps do not read, a validation rule someone hardcoded two years ago that now blocks a stage that got renamed, and a manager who eyeballs it in the weekly deal review. Governance that exists on a slide is a wish, not a system.
That matters more now than it did when the grid was invented. Ebsta and Pavilion put 2025 B2B win rates at 19 percent, down from about 29 percent the year before (Ebsta and Pavilion, 2025), and Gartner sizes the enterprise buying group at 17-plus people (Gartner, 2025). An opp that reached Proposal without a named economic buyer is a deal you cannot forecast against 17 stakeholders. When stage rules are vague, “inspection becomes opinion instead of evidence,” and opinion does not survive a QBR. The fix is not another slide. It is to make the policy executable in one place. I built the version where the governance is the config: one custom metadata type holds the rules, one service class enforces them, one validation surface blocks the save, and one test proves the gate fires. Change the policy, you change a metadata record, not code. Here is the build, bottom to top.
- L5One query is the audit surface1 SOQL
Because the rules are data, the entire governance model is a single SOQL query. Auditors and new admins read one screen instead of reverse-engineering seven scattered artifacts. The policy and the config are finally the same object.
- L4A test asserts the policy, not the plumbinggreen / red
Seed test metadata, run a deliberately incomplete opp through the class, assert the gate fires. Now "can a deal skip this gate" has a green-or-red answer instead of a manager who thinks they remember.
- L3A trigger or invocable blocks the saveaddError on save
A before-update trigger, or a Flow calling the class via invocable, collects the failures and calls addError with the message straight from the metadata. This is the last line: no valid save gets past a failed gate.
- L2A generic service class enforces it0 hardcoded names
One Apex class reads the active gates and checks the opp against each. No hardcoded stage names, no hardcoded field names. All the specificity lives in the metadata, so the class never changes when the policy does.
- L1Stage_Gate__mdt holds the policy as data1 row / rule
One custom metadata record per rule: stage, record type, required field, active flag, error message. The exit-criteria grid stops being a slide and becomes rows an admin owns. Adding a rule is inserting a record.
Locate your org on that ladder. Most orgs live below L1: the policy is prose on a slide and enforcement is scattered across whatever artifact each engineer reached for that quarter. Every rung above L1 collapses one source of drift. By L5 the rule set is one queryable object with one enforcement path and a test that fails loudly when someone breaks it. The rest of this piece builds each rung, with the artifact under every one.
Why hardcoded rules rot
The naive stage gate is a validation rule with the logic baked into the formula:
AND(
ISPICKVAL(StageName, "Proposal"),
OR(ISBLANK(Economic_Buyer__c), ISBLANK(CloseDate))
)
This holds until the day someone adds a stage, renames “Proposal” to “Proposal / Price,” or decides Enterprise deals need a security review field that SMB deals do not. Now you are editing formula text, deploying, and hoping you did not fatfinger a picklist API name. Every policy change is a code change. Every code change is a deploy. Every deploy is a chance to break prod. The rule set becomes a thing nobody dares touch, which is exactly how you end up with a validation rule blocking a stage that no longer exists and a second rule silently exempting the record type the first one was supposed to catch.
Watch how the cost of a policy change diverges between the two designs as the rule set grows.
View as table
| Item | Value |
|---|---|
| Add a rule | 4 actions |
| Change a rule | 4 actions |
| Turn one off | 3 actions |
| Audit all rules | 5 actions |
The hardcoded bars are the tax you pay forever. The metadata bars are flat at one, and that flatness is the entire argument: the effort to govern a rule stops scaling with the number of rules.
L1: the rules live in custom metadata
Move the policy into a custom metadata type, Stage_Gate__mdt. One record per rule. The fields are the rule:
Stage_Gate__mdt
Stage__c (Text) "Proposal"
Record_Type__c (Text) "Enterprise" // blank = all
Required_Field__c (Text) "Economic_Buyer__c"
Active__c (Checkbox)
Error_Message__c (Text) "Set the economic buyer before Proposal."
Now “Enterprise Proposal opps require an economic buyer, a close date, and a security review” is three metadata rows, not three lines of formula. A worked example of the rule set as data:
| Stage | Record Type | Required Field | Active | Error Message |
|---|---|---|---|---|
| Proposal | (all) | Economic_Buyer__c | yes | Set the economic buyer before Proposal. |
| Proposal | (all) | CloseDate | yes | Set a close date before Proposal. |
| Proposal | Enterprise | Security_Review__c | yes | Enterprise deals need a security review before Proposal. |
| Negotiation | (all) | Contract_Sent_Date__c | yes | Log the contract sent date before Negotiation. |
Adding a rule is inserting a record. Turning one off is unchecking Active__c. A RevOps admin owns it without touching Apex. And because custom metadata deploys as metadata, the rule set is version-controlled and promotes through change sets like everything else. The policy and the config are now the same object, which is the whole point of the L1 rung.
L2: a service class enforces it
The enforcement is one service class that reads the metadata and checks the opp. It has no hardcoded stage names or field names. It asks the metadata what the rules are and applies them.
public with sharing class StageGateService {
public static List<String> evaluate(Opportunity opp) {
List<String> failures = new List<String>();
for (Stage_Gate__mdt gate : [
SELECT Stage__c, Record_Type__c, Required_Field__c, Error_Message__c
FROM Stage_Gate__mdt WHERE Active__c = true
]) {
if (gate.Stage__c != opp.StageName) continue;
if (String.isNotBlank(gate.Record_Type__c)
&& gate.Record_Type__c != opp.RecordType.Name) continue;
if (opp.get(gate.Required_Field__c) == null) {
failures.add(gate.Error_Message__c);
}
}
return failures;
}
}
L3: the gate blocks the save
The service returns failures; something has to act on them. A before-update trigger, or a Flow calling evaluate via invocable, collects the failures and calls addError so the save is blocked with the message from the metadata.
The logic is generic. All the specificity lives in data. A before-update trigger stays a three-line handler that never changes: query the records, call evaluate, addError each failure. When the policy grows, the trigger does not.
L4: a test asserts the policy, not the plumbing
The payoff of pushing policy into data is testability. The test is trivial and it asserts the policy, not the plumbing:
@isTest static void proposalWithoutBuyerIsBlocked() {
Opportunity o = new Opportunity(StageName = 'Proposal', CloseDate = Date.today());
// Economic_Buyer__c deliberately null
List<String> fails = StageGateService.evaluate(o);
System.assert(fails.contains('Set the economic buyer before Proposal.'));
}
You seed test metadata, run the class against a synthetic opp, and prove that the gate fires when it should and stays quiet when the field is set. When someone asks “can a deal reach Proposal without an economic buyer,” the answer is a green test, not a manager’s memory. That is the third stat tile: one test replaces the tribal knowledge that used to live in one person’s head and walk out the door when they left.
L5: metadata as the audit surface
Because the rules are data, the entire rule set is queryable. “What are all the active gates on the Enterprise record type” is one SOQL query, not an archaeology dig through formula fields.
SELECT Stage__c, Required_Field__c, Error_Message__c
FROM Stage_Gate__mdt
WHERE Active__c = true AND Record_Type__c = 'Enterprise'
ORDER BY Stage__c
Auditors and new admins read the Stage_Gate__mdt records and see the whole governance model on one screen. That is the second stat tile, and it is the rung that pays off during an audit, an acquisition, or an admin handoff: the model is legible instead of reconstructed.
The worked example, reconciled to the tiles
Take the concrete change from the intro: “Enterprise deals now need a security review before Proposal.” Run it down both designs and reconcile to the three stat tiles.
| Step | Hardcoded formula path | Metadata path |
|---|---|---|
| Add the rule | Edit a VR formula, add an ISPICKVAL branch | Insert one Stage_Gate__mdt row |
| Apex changed | Sometimes, to keep flow and VR in sync | 0 lines |
| Deploy | Full deploy, prod-risk window | Metadata promotes via change set |
| Audit after | Read VRs, flows, Apex to confirm | 1 SOQL query returns it |
| Prove it works | Manual test in a sandbox, then hope | Existing test pattern, green or red |
The metadata column is the three tiles made concrete: zero Apex lines, one SOQL to audit, one test to prove it. The hardcoded column is the seven-artifact drift I inherited, one policy change at a time. That is not a hypothetical. Every “silently misfiring validation rule” started as a reasonable formula edit that nobody re-audited because auditing meant reading everything.
| Governance on a slide | Governance in metadata | |
|---|---|---|
| Where the rule lives | Confluence page reps do not read | Stage_Gate__mdt records in the org |
| Enforcement | A manager eyeballs the deal review | Service class plus addError on save |
| Adding a rule | Edit formula text, deploy, hope | Insert one metadata record |
| When a stage is renamed | Old validation rule silently misfires | Update one field on the record |
| Audit | Archaeology across VRs, flows, Apex | One SOQL query returns every gate |
| Proof it works | "Trust me" | A green test asserting the policy |
Here is how I would build it
- 1
Pick the one stage that bleeds
Usually where deals get sandbagged or slip. Write down its real exit criteria as sentences before you touch the org. This is the policy you are about to make executable.
- 2
Create Stage_Gate__mdt
Fields: Stage, Record Type (blank means all), Required Field (API name as text), Active, Error Message. The fields are the rule. One record per criterion.
- 3
Insert one record per criterion
Economic buyer, close date, security review for Enterprise. Three sentences from step one become three rows. No formula, no deploy.
- 4
Write the generic service class
It reads active gates, matches on stage and record type, checks the named field is populated, and returns the failure messages. No hardcoded names anywhere in the source.
- 5
Wire the trigger or invocable
A before-update trigger or a Flow calls evaluate() and addError()s each failure. The save is blocked with the message straight from the metadata.
- 6
Write the test that asserts the policy
Seed test metadata, run a bad opp through evaluate(), assert the gate fires. Now "can a deal skip this gate" has a green-or-red answer, not a memory.
- 7
Add the next stage by inserting rows
Next week, next stage, more records. Never edit a formula again. The engine scales by data, not by code.
Where this pattern generalizes
Stage gates are the obvious application, but the pattern is bigger than opportunities. Any governance you find yourself explaining on a slide is a candidate: territory assignment rules, discount approval thresholds, data-quality requirements before a record can be marked complete, the fields a case needs before it can escalate. Every one of those tends to live as a scattered mess of validation rules, flows, and tribal knowledge that drifts from whatever the slide says. And every one has the same shape: a condition on a record, a required state, and a message when it fails. That shape is what a metadata type plus a generic service class expresses.
The discipline that makes it work is resisting the urge to put logic in the service class. The moment you write if (stage == 'Proposal' && recordType == 'Enterprise') in Apex, you have rebuilt the hardcoded rule with extra steps. The service must stay dumb: read the rules, match generically on the fields the metadata names, apply, report. All the intelligence lives in the data. When a new requirement shows up, the test of whether you built this right is simple. If satisfying it takes a code change, you built a fancier hardcoded rule. If it takes a new metadata record, you built the engine.
The metadata version cannot drift the same way, because there is one place the rules live and one query that returns all of them. When the slide and the org disagree, you change one metadata record, not seven artifacts, and the test proves you changed it correctly.
Start with the one stage that matters: wire it as config, not code, testable rather than tribal, and ship it this week. Every stage after that is rows in a table, promoted through change sets like any other metadata. This is the same governed-execution pattern behind the control layer for AI agents, where the policy lives in data, the enforcement is generic, and the audit trail is a query, and it is the disciplined end of retiring Apex with Flows rather than piling more code onto the heap. Move the policy out of code, and the seven artifacts that never agreed become one row you can point to.
Keep reading
One email. Every week.
One email a week: a system I built or broke, with the config, the numbers, 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