Defining FSMs

Assembly and Machine

assembly[S, E](...) validates transitions at compile time. Pass the assembly inline to Machine(...) so orphan-override detection can see the expression tree. Compose with assembly[…](…) ++ assembly[…](…) (or top-level inline def fragments) so the macro can still see both sides.

{
  enum MyState derives Finite:
    case State1, State2, State3

  enum MyEvent derives Finite:
    case Event1, Event2, Event3

  import MyState.*, MyEvent.*

  val machine = Machine(
    assembly[MyState, MyEvent](
      State1 via Event1 to State2,
      State1 via Event2 to stay,
      State2 via Event3 to State3,
    )
  )

  Mermoid.diagram(
    MermaidVisualizer.stateDiagram(machine, Some(State1)),
    DocsDiagrams.diagramConfig,
  )
}
Event1Event2Event3
{
  enum MyState derives Finite:
    case State1, State2, State3

  enum MyEvent derives Finite:
    case Event1, Event2, Event3

  import MyState.*, MyEvent.*

  val machine = Machine(
    assembly[MyState, MyEvent](
      State1 via Event1 to State2,
      State1 via Event2 to stay,
      State2 via Event3 to State3,
    )
  )

  ZIO.scoped {
    for
      fsm   <- machine.start(State1)
      _     <- fsm.send(Event1)
      state <- fsm.currentState
    yield state
  }.asDoc
}
State2

Compile-time safety

Mechanoid catches many mistakes before runtime:

CheckWhat fails
Finite derivationNon-sealed or empty types
Duplicate transitionsSame state+event twice without @@ Aspect.overriding
Orphan overrides@@ Aspect.overriding with nothing to override (warning)
Inline assemblyMachine(valAssembly) when orphan detection needs the tree
Produced events.producing returning an unrelated event type
Case collisionsDistinct cases that hash alike (CaseHasher); rename the case

all[T] expands to every leaf under T. Here both processing leaves cancel the same way:

{
  sealed trait ProcState derives Finite
  sealed trait Processing  extends ProcState derives Finite
  case object SpecialState extends Processing
  case object RegularState extends Processing
  case object Cancelled    extends ProcState
  case object Escalated    extends ProcState

  enum ProcEvent derives Finite:
    case Cancel, Escalate

  import ProcEvent.*

  val groupMachine = Machine(
    assembly[ProcState, ProcEvent](
      all[Processing] via Cancel to Cancelled
    )
  )

  Mermoid.diagram(
    MermaidVisualizer.flowchart(groupMachine),
    DocsDiagrams.diagramConfig,
  )
}
CancelCancel
{
  sealed trait ProcState derives Finite
  sealed trait Processing  extends ProcState derives Finite
  case object SpecialState extends Processing
  case object RegularState extends Processing
  case object Cancelled    extends ProcState

  enum ProcEvent derives Finite:
    case Cancel

  import ProcEvent.*

  val groupMachine = Machine(
    assembly[ProcState, ProcEvent](
      all[Processing] via Cancel to Cancelled
    )
  )

  ZIO.scoped {
    for
      fromSpecial <- groupMachine.start(SpecialState).flatMap(fsm => fsm.send(Cancel) *> fsm.currentState)
      fromRegular <- groupMachine.start(RegularState).flatMap(fsm => fsm.send(Cancel) *> fsm.currentState)
    yield (fromSpecial.toString, fromRegular.toString)
  }.asDoc
}
(Cancelled,Cancelled)

Intentional overrides

When one leaf needs different behavior, declare the broader edge first, then the specific edge with @@ Aspect.overriding (last wins). Here SpecialState escalates instead of cancelling:

{
  sealed trait ProcState derives Finite
  sealed trait Processing  extends ProcState derives Finite
  case object SpecialState extends Processing
  case object RegularState extends Processing
  case object Cancelled    extends ProcState
  case object Escalated    extends ProcState

  enum ProcEvent derives Finite:
    case Cancel

  import ProcEvent.*

  val overrideMachine = Machine(
    assembly[ProcState, ProcEvent](
      RegularState via Cancel to Cancelled,
      SpecialState via Cancel to Cancelled,
      (SpecialState via Cancel to Escalated) @@ Aspect.overriding,
    )
  )

  Mermoid.diagram(
    MermaidVisualizer.flowchart(overrideMachine),
    DocsDiagrams.diagramConfig,
  )
}
CancelCancelCancel
{
  sealed trait ProcState derives Finite
  sealed trait Processing  extends ProcState derives Finite
  case object SpecialState extends Processing
  case object RegularState extends Processing
  case object Cancelled    extends ProcState
  case object Escalated    extends ProcState

  enum ProcEvent derives Finite:
    case Cancel

  import ProcEvent.*

  val overrideMachine = Machine(
    assembly[ProcState, ProcEvent](
      RegularState via Cancel to Cancelled,
      SpecialState via Cancel to Cancelled,
      (SpecialState via Cancel to Escalated) @@ Aspect.overriding,
    )
  )

  ZIO.scoped {
    for
      regular <- overrideMachine.start(RegularState).flatMap(fsm => fsm.send(Cancel) *> fsm.currentState)
      special <- overrideMachine.start(SpecialState).flatMap(fsm => fsm.send(Cancel) *> fsm.currentState)
    yield (regular.toString, special.toString)
  }.asDoc
}
(Cancelled,Escalated)

Timeouts on transitions

Attach a deadline with @@ Aspect.timeout(duration, timeoutEvent). Fiber-based timeouts fire in-process; pair with Durable Timeouts when deadlines must survive node failure. DocSpecs send the timeout event directly rather than waiting out the clock.

Entry/exit effects on assemblies (.onEnter / .onExit) and per-transition .onEntry / .producing are covered on Side Effects.

{
  enum PayState derives Finite:
    case Pending, AwaitingPayment, Paid, Cancelled

  enum PayEvent derives Finite:
    case StartPayment, ConfirmPayment, PaymentTimeout

  import PayState.*, PayEvent.*

  val timedMachine = Machine(
    assembly[PayState, PayEvent](
      (Pending via StartPayment to AwaitingPayment) @@ Aspect.timeout(30.minutes, PaymentTimeout),
      AwaitingPayment via ConfirmPayment to Paid,
      AwaitingPayment via PaymentTimeout to Cancelled,
    )
  )

  ZIO.scoped {
    for
      fsm   <- timedMachine.start(Pending)
      _     <- fsm.send(StartPayment)
      _     <- fsm.send(PaymentTimeout)
      state <- fsm.currentState
    yield state
  }.asDoc
}
Cancelled

Composable assemblies

Build reusable fragments and combine them with ++ / combine. Duplicates across combined assemblies are still detected at compile time when composed inline into Machine.

Block form assemblyAll[S, E]: avoids commas between specs when the list gets long.

{
  enum ShipState derives Finite:
    case Draft, Paid, Packed, Shipped

  enum ShipEvent derives Finite:
    case Pay, Pack, Ship

  import ShipState.*, ShipEvent.*

  val machine = Machine(
    assembly[ShipState, ShipEvent](
      Draft via Pay to Paid
    ) ++ assembly[ShipState, ShipEvent](
      Paid via Pack to Packed,
      Packed via Ship to Shipped,
    )
  )

  Mermoid.diagram(
    MermaidVisualizer.stateDiagram(machine, Some(Draft)),
    DocsDiagrams.diagramConfig,
  )
}
ShipPayPack
{
  enum ShipState derives Finite:
    case Draft, Paid, Packed, Shipped

  enum ShipEvent derives Finite:
    case Pay, Pack, Ship

  import ShipState.*, ShipEvent.*

  val machine = Machine(
    assembly[ShipState, ShipEvent](
      Draft via Pay to Paid
    ) ++ assembly[ShipState, ShipEvent](
      Paid via Pack to Packed,
      Packed via Ship to Shipped,
    )
  )

  ZIO.scoped {
    for
      fsm   <- machine.start(Draft)
      _     <- fsm.send(Pay)
      _     <- fsm.send(Pack)
      _     <- fsm.send(Ship)
      state <- fsm.currentState
    yield state
  }.asDoc
}
Shipped

Next: Side Effects.