Mahbubul Alam.
Product Engineering

Building a Company Formation Wizard: Modeling Complex Business Rules as Data

Jul 20262 min read
Building a Company Formation Wizard: Modeling Complex Business Rules as Data

The naive approach, and why it breaks

The first instinct when building a multi-step formation wizard that calculates state filing fees is a big switch statement or a lookup object keyed by state code, with the fee calculation logic inlined per state. This works for the first five states. By state fifteen, you have a 2,000-line file mixing UI-adjacent logic with regulatory facts that change independently of any code you're writing — a state raising its filing fee shouldn't require a pull request and a deploy.

Rules as data, not code

// state-rules.json — data, not logic
{
  "DE": { "baseFee": 90, "franchiseTaxMin": 300, "registeredAgentRequired": true },
  "WY": { "baseFee": 100, "franchiseTaxMin": 0, "registeredAgentRequired": true },
  "CA": { "baseFee": 70, "franchiseTaxMin": 800, "registeredAgentRequired": true }
}

The application code becomes a single, generic evaluator that reads this configuration — calculateFee(stateCode, entityType) — rather than a different code path per state. Updating Delaware's franchise tax minimum becomes a data change reviewed by whoever owns the compliance relationship, not a code change that needs an engineer and a deploy pipeline. This distinction — who needs to be involved to make a change — is the real test of whether something belongs in code or in data.

Where the model gets genuinely complex

Filing fees are the simple case. UK LTD formation has different documentation requirements than a US LLC. Some US states require a registered agent; others don't. Certain entity types have different tax election implications. The wizard's step sequence itself needed to branch based on jurisdiction and entity type — not every applicant sees the same steps in the same order. Modeling this as a rules engine (each step has eligibility conditions evaluated against the application's current state) kept the step logic itself generic, with jurisdiction-specific behavior living entirely in configuration.

The state machine underneath the wizard

Beyond fee calculation, the entire application — from "draft" through "payment received," "filed with state," "EIN obtained," "bank account setup," to "complete" — is modeled as an explicit state machine, not a status string mutated ad hoc from different parts of the codebase. Every transition is a named, auditable event, which turned out to matter more than expected once customer support needed to answer "where exactly did this application get stuck" without reading application logs.

const transitions = {
  draft: ["payment_pending"],
  payment_pending: ["filing_in_progress", "payment_failed"],
  filing_in_progress: ["filed", "filing_rejected"],
  filed: ["ein_pending"],
  ein_pending: ["complete"],
};

function canTransition(from, to) {
  return transitions[from]?.includes(to) ?? false;
}

Why this mattered for a non-technical team

The real payoff of rules-as-data wasn't purely technical — it changed who could safely make changes. Someone on the compliance side could update a state's fee schedule directly, with validation, without needing an engineer in the loop, and the audit trail (the state machine's transition log) meant support could answer customer questions about application status without escalating to engineering. Modeling the domain correctly turned out to be as much an organizational decision as a technical one.

#saas#business-logic#product-engineering