Skip to content

FSM

@voltage/fsm provides finite state machines that integrate directly with NestJS routing and session storage. Each state in the machine maps to a controller endpoint — GET routes render the view for that state, POST/PUT/PATCH routes handle transitions. After a transition, the interceptor redirects the client (via HTMX) to the GET route for the new state automatically.

State and context are persisted to the session between requests, so the machine picks up exactly where it left off.

Terminal window
yarn add @voltage/fsm @voltage/session @voltage/htmx @voltage/url @voltage/event-manager @voltage/logger

Import FsmModule once in your root module:

import { FsmModule } from '@voltage/fsm';
@Module({
imports: [FsmModule],
})
export class AppModule {}

StateMachine() is a factory that returns a NestJS provider class. Call it with your state and context types and a configuration object:

import { StateMachine } from '@voltage/fsm';
export type CheckoutState = 'cart' | 'shipping' | 'payment' | 'confirmation';
export interface CheckoutContext {
shippingAddress?: Address;
paymentMethod?: string;
}
export const CheckoutMachine = StateMachine<CheckoutState, CheckoutContext>({
name: 'checkout',
defaultState: 'cart',
defaultContext: {},
});

Register it as a provider in the module that owns the checkout flow:

@Module({
providers: [CheckoutMachine],
controllers: [CheckoutController],
})
export class CheckoutModule {}

@Route(MachineClass, ...states) associates a controller method with a machine and one or more states. The interceptor uses this metadata to decide whether the current request is valid for the machine’s state, and to resolve the redirect URL after a transition.

GET methods render the view for a state. POST/PUT/PATCH methods handle transitions. Use '*' as a wildcard to match any state:

import { Controller, Get, Post, Body } from '@nestjs/common';
import { Route, Machine } from '@voltage/fsm';
import { CheckoutMachine, CheckoutContext, CheckoutState } from './checkout.machine';
@Controller('checkout')
export class CheckoutController {
@Get()
@Route(CheckoutMachine, 'cart')
cart(@Machine() machine: CheckoutMachine) {
return <CartView items={machine.context.items} />;
}
@Get('shipping')
@Route(CheckoutMachine, 'shipping')
shipping(@Machine() machine: CheckoutMachine) {
return <ShippingView />;
}
@Get('payment')
@Route(CheckoutMachine, 'payment')
payment(@Machine() machine: CheckoutMachine) {
return <PaymentView address={machine.context.shippingAddress} />;
}
@Get('confirmation')
@Route(CheckoutMachine, 'confirmation')
confirmation(@Machine() machine: CheckoutMachine) {
return <ConfirmationView />;
}
}

@Machine() is a parameter decorator that injects the current machine instance. Use it to read state and context, and to call transition():

@Get()
@Route(CheckoutMachine, 'cart', '*')
cart(@Machine() machine: CheckoutMachine) {
const { items } = machine.context;
return <CartView items={items} />;
}

The '*' wildcard above means this route serves as a fallback — if the machine is in any state that has no other matching GET handler, it redirects here. Useful for the entry point of a flow.

Call machine.transition() inside a POST/PUT/PATCH handler to move to a new state. Return the machine instance from the handler — the interceptor detects this and redirects to the new state’s GET route automatically:

@Post('shipping')
@Route(CheckoutMachine, 'cart')
async toShipping(
@Machine() machine: CheckoutMachine,
@Body() body: ShippingBody,
) {
await machine.transition('shipping', {
shippingAddress: body.address,
});
return machine;
}
@Post('payment')
@Route(CheckoutMachine, 'shipping')
async toPayment(@Machine() machine: CheckoutMachine) {
await machine.transition('payment');
return machine;
}

The second argument to transition() controls context handling:

// Keep existing context unchanged
await machine.transition('payment');
// Merge partial context into existing context
await machine.transition('payment', { shippingAddress: address });
// Reset context to defaultContext, then transition
await machine.transition('cart', true);

To send the user back to the beginning, reset() returns to defaultState with defaultContext:

@Post('restart')
@Route(CheckoutMachine, '*')
async restart(@Machine() machine: CheckoutMachine) {
await machine.reset();
return machine;
}

StateTransitionEvent is emitted before every transition. Listen to it to validate the transition and block it when conditions are not met by setting event.shouldTransition = false:

import { Injectable } from '@nestjs/common';
import { OnEvent } from '@voltage/event-manager';
import { StateTransitionEvent } from '@voltage/fsm';
@Injectable()
export class CheckoutGuard {
@OnEvent(StateTransitionEvent)
async onTransition(event: StateTransitionEvent) {
if (event.state === 'payment' && !event.machine.context.shippingAddress) {
event.shouldTransition = false;
}
}
}

The event carries:

interface StateTransitionEvent {
/** The machine attempting to transition. */
machine: StateMachineFacade;
/** The target state. */
state: string;
/** The context that will be set after the transition. */
nextContext: AnyContext;
/** Set to false to abort the transition. */
shouldTransition: boolean;
}

StateMachine() accepts the following options:

interface StateMachineConfiguration<State, Context> {
/** Unique name for this machine. Used as the session key. */
name: string;
/** The state the machine starts in, and returns to on reset(). */
defaultState: State;
/** The context the machine starts with, and returns to on reset(). */
defaultContext: Context;
}