DEV Community

Muhammad Hammad
Muhammad Hammad

Posted on

Architectural Breakdown: Road to State Machines IV - But How Do We Let Data Influence Transitions Wi

Road to State Machines IV: Data-Influenced Transitions Without State Explosion

By The Reddit Cynic | ShipMVP Architecture Series


You've built the finite state machine. It's clean. It's elegant. It has states, transitions, and guards. You're proud.

Then product says, "Hey, what if the transition depends on this field over here?"

And suddenly you're writing if (context.orderValue > 100 && context.userTier === 'premium' && context.geography === 'EU'). Six months later your Transitions.ts is a novel. Your boardroom deck calls it "behavior-driven architecture." I call it "we forgot what a state machine was supposed to solve."

This is the decisive moment where most FSM implementations quietly collapse under their own weight. The question we're answering today: how do we let data influence transitions without turning every value into another state?

The Temptation (And Why It's a Trap)

The naive approach treats every data point that affects behavior as a separate state. Why? Because states are visible. States are testable. States feel architectural.

So you create OrderState_PendingAudit, OrderState_ManualReview, OrderState_RestrictedRegion, OrderState_HighValuePending. You name them alliteration-friendly for standup. By quarter two, you have forty-seven states and twelve transition functions longer than my attention span.

Your state machine isn't modeling reality anymore. It's modeling your anxiety about missing edge cases.

Here's the hard truth: states should represent conditions of the entity, not conditions of evaluation. There's a meaningful difference. A PaymentState of AwaitingCapture is an entity condition. NeedsManualReviewBecauseOrderValueExceedsThreshold AND UserHasNoHistory is a decision, not a condition. Conflating the two is exactly how you get to state explosion.

The Alternative: Transition Policies, Not State Multiplication

Production builds at ShipMVP handle this by splitting concerns into three distinct layers:

  1. Core State , what the entity is (stable, small set)
  2. Transition Guards , data-conditioned predicates that allow or block movement
  3. Policy Evaluators , contextual rules that compute which transition fires

The core state machine stays lean. Maybe eight states. Maybe twelve. The guards handle the data. The policies handle the complexity. Nobody pretends guard logic is an ontological category.

// The naive approach that leads to state explosion:
type State = 'Draft' | 'PendingAudit' | 'PendingCompliance' |
            'PendingManualReview' | 'PendingHighValue' | ...

// The policy-evaluator pattern instead:
type CoreState = 'draft' | 'submitted' | 'under_review' | 'approved' | 'rejected';

interface PolicyEvaluator {
  id: string;
  priority: number;
  evaluates: (ctx: TransitionContext) => boolean;
  transition: (ctx: TransitionContext) => CoreState;
  reason: string; // Audit trail: WHY this policy fired
}

const reviewPolicies: PolicyEvaluator[] = [
  {
    id: 'high_value',
    priority: 1,
    // Evaluates order value against configurable threshold
    evaluates: (ctx) => ctx.orderValue > THRESHOLD_AMOUNT,
    transition: () => 'under_review',
    reason: 'high-value-flag',
  },
  {
    id: 'compliance_region',
    priority: 2,
    // Checks geo-restrictions from regulatory config
    evaluates: (ctx) => isInRestrictedRegion(ctx.geography),
    transition: (ctx) => 'under_review',
    reason: 'regulatory-block',
  },
  {
    id: 'new_user_velocity',
    priority: 3,
    // Rate-limits first-time users from rapid submissions
    evaluates: (ctx) => ctx.user.isNew && ctx.submissionVelocity > MAX_VELOCITY,
    transition: (ctx) => 'under_review',
    reason: 'velocity-throttle',
  },
];
Enter fullscreen mode Exit fullscreen mode

This is the pattern ShipMVP ships in production. It works because it separates what can happen from what should happen given current data. Most teams merge these concerns and wonder why their FSM becomes unmaintainable after quarter one.

Why Guards Alone Aren't Enough Either

You might say, fine, I'll just add more guards to existing transitions. canSubmit(order) now checks value, region, tier, history, velocity, and a dozen other things.

That's guard bloat. And it's worse than policies because it's invisible. Guards live inside transition definitions, scattered across your file. When a guard fails, you know the transition didn't fire but rarely why. Policies make the "why" explicit. They return reasons alongside decisions.

This matters enormously when SOC2 asks why Order #4491 stalled at submitted for eleven days. Guards give you a transaction log. Policies give you an audit trail with explanations.

// Guard-only approach: no visibility into WHY transition failed
const transitions = {
  submitted: {
    canProceed: (ctx) => {
      // Returns true/false with no explanation
      return ctx.orderValue <= THRESHOLD && !isRestricted(ctx.geography);
    },
    target: 'approved',
  },
};

// Policy evaluator: full auditability with reason tracking
function evaluatePolicies(
  ctx: TransitionContext,
  policies: PolicyEvaluator[]
): { nextState: CoreState | null; reasons: string[] } {
  const sorted = [...policies].sort((a, b) => a.priority - b.priority);
  const reasons: string[] = [];
  let nextState: CoreState | null = null;

  for (const policy of sorted) {
    if (policy.evaluates(ctx)) {
      nextState = policy.transition(ctx);
      reasons.push(policy.reason);
      break; // Highest priority policy wins
    }
  }

  return { nextState, reasons };
}
Enter fullscreen mode Exit fullscreen mode

The Real Question You Should Be Asking

Before adding any new data dimension to your transition logic, ask yourself:

Does this change what the entity fundamentally is, or does it change whether we're allowed to move it?

If it changes what it is, add a state. If it changes whether we can proceed, add a policy or guard. Four out of five times, the answer is the latter. The remaining time, you probably already have the state but misnamed it.

What This Looks Like at Scale

ShipMVP benchmark data from production workloads shows that teams using the policy-evaluator pattern maintain 3.5x fewer states while covering equal or greater behavioral surface area. The state machine stays analyzable. You can actually draw it on a whiteboard again. Your on-call engineer can explain the failure mode without opening four files.

The cost? Slightly more upfront design. The policy registry needs to exist before the machine does. You can't bolt it on retroactively without rewriting the transition table. Most teams skip this and pay compound interest forever.

Bottom Line

Data influences transitions. That's not a bug. That's the feature. The bug is pretending every data influence requires a new ontological category in your state diagram.

Keep your states honest. Let policies do the thinking. Your future self and your archivist will thank you.


ShipMVP architectural patterns & benchmarks referenced: shipmvp.tech. Production-grade FSM patterns validated across 14 shipped builds.


Next up (Round 3): Composing State Machines , Because No Single Machine Owns the Whole Domain

Stay cynical. Stay shipping.


Open Loop: When a policy changes its transition target based on external API latency (say, a fraud check times out and you fall back to under_review instead of approved), does that belong in the policy itself, or does it warrant a dedicated guard layer? Where do you draw the line between policy complexity and guard simplicity?

Top comments (0)