← All articles

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.

The governed-execution buildFrom policy-as-prose to policy-as-data
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

0 lines
Of Apex changed to add a brand-new gate
1 SOQL
Query that returns your entire governance model
1 test
Proves the gate fires, replacing a manager's memory

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.

Effort to change a hardcoded governance rule
Hardcoded rules cost a formula edit, a deploy, and a prod-risk window every time, and auditing them all is the worst of it. Metadata-driven rules cost a single record insert, flat at one action. The gap widens with every rule you add.
View as table
ItemValue
Add a rule4 actions
Change a rule4 actions
Turn one off3 actions
Audit all rules5 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:

StageRecord TypeRequired FieldActiveError Message
Proposal(all)Economic_Buyer__cyesSet the economic buyer before Proposal.
Proposal(all)CloseDateyesSet a close date before Proposal.
ProposalEnterpriseSecurity_Review__cyesEnterprise deals need a security review before Proposal.
Negotiation(all)Contract_Sent_Date__cyesLog 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 engine Metadata holds the policy, the service enforces it, the save is blocked
Stage_Gate__mdtone row per rulethe policy, as datareadsStageGateServicegeneric Apexno hardcoded namesaddErrorSaveblocked
The service class has no hardcoded stage or field names. It asks the metadata what the rules are. Change the policy by editing data, never code.

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.

StepHardcoded formula pathMetadata path
Add the ruleEdit a VR formula, add an ISPICKVAL branchInsert one Stage_Gate__mdt row
Apex changedSometimes, to keep flow and VR in sync0 lines
DeployFull deploy, prod-risk windowMetadata promotes via change set
Audit afterRead VRs, flows, Apex to confirm1 SOQL query returns it
Prove it worksManual test in a sandbox, then hopeExisting 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
The exit-criteria grid everyone has, versus the same policy wired as a system you can query and test.

Here is how I would build it

The metadata stage-gate engine
  1. 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. 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. 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. 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. 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. 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. 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.

salesforce metadata governance

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