Model the Workflow, Not the Backend
Give clients the stateful behavior a workflow needs without rebuilding the service behind the API.
Why teams use this
Predictability is one of the main benefits of a simulated API. A handler that always returns the same response for the same input is easy to understand, debug, and test. Some workflows need more: a client creates a record and reads it later, an approval changes what the next request returns, or a scenario temporarily enables a failure.
It is tempting to respond by copying the real backend’s data model, lifecycle, and business rules. That makes the simulator slower to build and harder to reset, while still leaving it less trustworthy than the real service. The simulator becomes a second backend that the team must maintain.
How it works
A context holds state for nearby routes. A group shares contexts across specs of one API; a store shares facts across groups. Scenarios are named setup functions, and the REPL is the live terminal prompt.
Model the smallest observable state machine that supports the client workflow:
- Start with a fixed response or a deterministic function of the request.
- Add context state only when a later request must observe an earlier action.
- Add a shared store only when separate API groups must observe the same fact.
- Use scenarios to establish named, repeatable starting states.
- Use the REPL to inspect or temporarily steer the current state while developing.
The state represents facts the client needs to observe, not the backend’s tables, queues, caches, jobs, or internal service boundaries. Prefer an explicit flag or a short collection over infrastructure that reproduces how production arrives at the same response.
Use the simulation as an executable reference
Commit the spec, handlers, scenarios, and tests together so other developers can see which behavior the simulation covers. When the spec changes, regenerate the types, run the TypeScript compiler, and run the HTTP tests. Keep tests against the real backend for behavior the simulation leaves out.
Example
A payment workflow may need only an outcome flag and a small set of placed orders:
// api/routes/_.context.ts
export class Context {
paymentOutcome: "approved" | "declined" = "approved";
private orders = new Set<string>();
placeOrder(id: string): "placed" | "declined" {
if (this.paymentOutcome === "declined") return "declined";
this.orders.add(id);
return "placed";
}
hasOrder(id: string): boolean {
return this.orders.has(id);
}
reset(): void {
this.paymentOutcome = "approved";
this.orders.clear();
}
}
This is enough to simulate create, read, decline, and reset. It does not need a payment ledger, asynchronous settlement, inventory reservations, or the real service’s retry rules unless a client workflow specifically depends on one of those behaviors.
Make important starting states explicit with scenarios: named setup functions under api/scenarios/. Counterfact generates the Scenario type and invokes an export named startup during initialization. Enter .scenario declinedPayment at the terminal’s REPL prompt to apply the other export:
// api/scenarios/index.ts
import type { Scenario } from "../types/index.js";
export const startup: Scenario = ($) => $.context.reset();
export const declinedPayment: Scenario = ($) => {
$.context.paymentOutcome = "declined";
};
declinedPayment changes only the payment outcome; it preserves orders and unrelated state. Use await simulator.reset() explicitly when a fresh baseline is required. The complete payment retry recipe connects this model to HTTP handlers and an automated decline, recovery, permission, and reset check.
You can also change that state from the REPL:
⬣> .scenario declinedPayment
⬣> context.paymentOutcome = "approved"
What you get
- Predictable behavior when handlers use fixed values or deterministic functions, with controlled state and delays. Local execution or reset alone does not make random responses deterministic.
- A small model that is easy to understand, reset, and commit with the client.
- Named scenarios that set up test cases without production data.
- Clear reasons to keep targeted tests against the real backend: the simulator intentionally does not prove unmodeled behavior.
A useful test is: if removing a piece of state would not change anything the client can observe in a supported workflow, leave it out. If the simulator needs migrations, background workers, or a production-shaped database, first consider whether a fixed outcome, context flag, or scenario can express the same client-visible behavior.
Keep exploring
- Mock APIs with Dummy Data — begin with fixed and deterministic responses
- Scenario Scripts — save repeatable starting states
- Live Server Inspection with the REPL — inspect and steer the current state
- Share State Across API Groups — introduce application-level state when a workflow crosses API groups
- Hybrid Proxy — use the real backend selectively when its behavior is the thing you need to exercise