Trellis.StateMachine — API Reference
Header
- Package:
Trellis.StateMachine - Namespace:
Trellis.StateMachine - Purpose: Wraps Stateless state transitions in Trellis
Result<TState>APIs and provides lazy state-machine construction for aggregate materialization scenarios.
See also: trellis-api-cookbook.md — recipes using this package.
Use this file when
- You are wrapping Stateless transitions in Trellis
Result<TState>values. - You need lazy state-machine construction for aggregates materialized by an ORM.
- You need the exact invalid-transition behavior of
FireResult.
Patterns Index
| Goal | Canonical API / pattern | See |
|---|---|---|
| Fire a Stateless trigger and get a Trellis result | stateMachine.FireResult(trigger) |
StateMachineExtensions |
| Store a state machine inside an aggregate | LazyStateMachine<TState,TTrigger> with state accessor/mutator delegates |
LazyStateMachine<TState, TTrigger> |
| Treat invalid transitions as domain-invariant breaches | Let FireResult short-circuit when CanFire returns false and return Error.InvariantViolation |
Behavioral notes |
| Apply business mutations after successful transition | Call .FireResult(...), then mutate/domain-event in a .Tap(...) or explicit success branch |
Code examples, Cookbook Recipe 9 |
Common traps
- Do not model triggers as raw strings when the domain already has a typed enum/value object.
- Do not put business side effects in Stateless configuration unless they are purely transition mechanics. Keep domain mutation and events after
FireResultsucceeds. FireResultdoes not make Stateless thread-safe; external synchronization is still required for concurrent use.
Types
StateMachineExtensions
Declaration
public static class StateMachineExtensions
Constructors
- None. This is a static class.
Properties
| Name | Type | Description |
|---|---|---|
| None | — | This static class exposes no public properties. |
Methods
| Signature | Returns | Description |
|---|---|---|
public static Result<TState> FireResult<TState, TTrigger>(this StateMachine<TState, TTrigger> stateMachine, TTrigger trigger) where TState : notnull where TTrigger : notnull |
Result<TState> |
Pre-checks with stateMachine.CanFire(trigger) (which honors PermitIf/IgnoreIf guards). When permitted, calls stateMachine.Fire(trigger) and returns Result.Ok(stateMachine.State). When not permitted, returns Error.InvariantViolation.ForReason(code: FaultCodes.StateMachineInvalidTransition, detail: $"Trigger '{trigger}' is not permitted from state '{stateMachine.State}'.") (HTTP 422) without invoking Fire(trigger), so user-configured OnUnhandledTrigger callbacks do not run through FireResult. Consumers who need those callbacks must call Stateless Fire directly. InvalidOperationException thrown while evaluating a guard is translated to Error.InvariantViolation with the exception message; other exception types and exceptions from user entry/exit/transition actions propagate untouched. Independent of Stateless's exception message format. |
LazyStateMachine<TState, TTrigger>
Declaration
public sealed class LazyStateMachine<TState, TTrigger>
where TState : notnull
where TTrigger : notnull
Constructors
public LazyStateMachine(Func<TState> stateAccessor, Action<TState> stateMutator, Action<StateMachine<TState, TTrigger>> configure)
ThrowsArgumentNullExceptionwhenstateAccessor,stateMutator, orconfigureisnull. The constructor stores the delegates only; it does not invokestateAccessor,stateMutator, orconfigure.
Properties
| Name | Type | Description |
|---|---|---|
Machine |
StateMachine<TState, TTrigger> |
Returns _machine ??= CreateMachine(). First access constructs new StateMachine<TState, TTrigger>(_stateAccessor, _stateMutator), invokes _configure(machine), caches the instance, and returns it. |
Methods
| Signature | Returns | Description |
|---|---|---|
public Result<TState> FireResult(TTrigger trigger) |
Result<TState> |
Delegates to Machine.FireResult(trigger). On first use, this also triggers lazy creation and configuration of the underlying StateMachine<TState, TTrigger>. |
Extension methods
StateMachineExtensions
public static Result<TState> FireResult<TState, TTrigger>(
this StateMachine<TState, TTrigger> stateMachine,
TTrigger trigger)
where TState : notnull
where TTrigger : notnull
Behavioral notes
StateMachineExtensions.FireResultdoes not make Stateless thread-safe. Concurrent use of the sameStateMachine<TState, TTrigger>instance still requires external synchronization. Because Stateless is single-threaded by contract, theCanFire+Firepre-check pattern is race-free when used as documented.StateMachineExtensions.FireResultnull-checksstateMachineand throwsArgumentNullExceptionfor anullreceiver before any Trellis error conversion occurs.LazyStateMachine<TState, TTrigger>is also not thread-safe. Its lazy initialization uses_machine ??= CreateMachine()with no locking.- Invalid-transition detection uses
StateMachine.CanFire(trigger)(which honorsPermitIf/IgnoreIfguards) — no message-string parsing, so it is resilient to Stateless library upgrades. - When
CanFirereturnsfalse,FireResultreturnsError.InvariantViolation.ForReason(code: FaultCodes.StateMachineInvalidTransition, detail: $"Trigger '{trigger}' is not permitted from state '{stateMachine.State}'.")(HTTP 422) without invokingFire, so user-configuredOnUnhandledTriggercallbacks do not run. Call StatelessFiredirectly when that callback policy is desired. InvalidOperationExceptionthrown while evaluating a guard is translated toError.InvariantViolationwith the guard exception message. Other exception types and exceptions thrown by user entry, exit, transition, accessor, mutator, or configuration code are not swallowed.LazyStateMachine<TState, TTrigger>exists to defer state-machine construction until after entity state is available, which is useful when ORMs materialize an object before populating its state properties.
Scope boundary — async and parameterized triggers
Trellis.StateMachine deliberately wraps only the synchronous, parameterless trigger shape Fire(TTrigger). Stateless also supports:
FireAsync(TTrigger)and the parameterized async overloads for state machines withOnEntryAsync/OnExitAsync/OnActivateAsynccallbacks.- Parameterized triggers via
SetTriggerParameters<TArg>(...)(returning aTriggerWithParameters<TArg>token) andFire(triggerWithParameters, arg)/FireAsync(triggerWithParameters, arg)for passing context (timestamps, cancellation reasons, etc.) into transition actions.
These shapes are not wrapped today. Consumers who need them must call the underlying Stateless APIs directly and translate exceptions themselves; the Trellis Result<TState> pipeline ends at the sync, no-arg boundary. If a future Trellis version adds FireResultAsync / parameterized overloads, they will follow the same CanFire short-circuit design and will use library-source ConfigureAwait(false) on awaited Stateless operations.
Code examples
Use FireResult on a regular Stateless machine
using Stateless;
using Trellis;
using Trellis.StateMachine;
enum OrderState { Draft, Submitted, Cancelled }
enum OrderTrigger { Submit, Cancel }
var machine = new StateMachine<OrderState, OrderTrigger>(OrderState.Draft);
machine.Configure(OrderState.Draft)
.Permit(OrderTrigger.Submit, OrderState.Submitted)
.Permit(OrderTrigger.Cancel, OrderState.Cancelled);
Result<OrderState> submitResult = machine.FireResult(OrderTrigger.Submit);
Result<OrderState> invalidResult = machine.FireResult(OrderTrigger.Submit);
Use LazyStateMachine<TState, TTrigger>
using Stateless;
using Trellis;
using Trellis.StateMachine;
enum DocumentState { Draft, Published }
enum DocumentTrigger { Publish }
var state = DocumentState.Draft;
var lazyMachine = new LazyStateMachine<DocumentState, DocumentTrigger>(
() => state,
s => state = s,
machine => machine.Configure(DocumentState.Draft)
.Permit(DocumentTrigger.Publish, DocumentState.Published));
Result<DocumentState> result = lazyMachine.FireResult(DocumentTrigger.Publish);
StateMachine<DocumentState, DocumentTrigger> machine = lazyMachine.Machine;
Cross-references
- trellis-api-core.md —
Result<T>,Error.InvariantViolation. - trellis-api-cookbook.md — Recipe 9: state-machine
CanFire+Firepattern withFireResult. - trellis-api-asp.md — how
Error.InvariantViolationrenders as HTTP 422 (top-leveldetailreadsError.Detail).
Breaking changes from v1
- Package renamed:
Trellis.Stateless→Trellis.StateMachine. Vendor independence in the name, not the implementation — the underlying Stateless library is still referenced directly, andStateMachine<TState, TTrigger>from theStatelessnamespace remains visible in user code. - Namespace renamed:
Trellis.Stateless→Trellis.StateMachine. Replaceusing Trellis.Stateless;withusing Trellis.StateMachine;. - Method signatures are unchanged:
StateMachineExtensions.FireResult<TState, TTrigger>(...)andLazyStateMachine<TState, TTrigger>keep the same shape. - Rejected transitions now return
Error.InvariantViolation(reason codestate-machine.invalid-transition), notError.InvalidInput. A disallowed lifecycle transition is a domain-invariant breach, not inbound-input validation. Both still map to HTTP 422, so the wire status is unchanged; but code that matches on the error type or readsError.InvalidInput.Rulesmust update toError.InvariantViolationand itsCode. - No metapackage redirect. The old
Trellis.Statelesspackage is not shipped and there is no shim. Update yourPackageReferencedirectly.