kFSM is Finite State Machinery for Kotlin.
kFSM provides a type-safe, production-ready state machine framework with:
- Pure decision logic - Transitions return
Decisionobjects describing state changes and effects - Transactional outbox - Effects stored atomically with state changes for reliable delivery
- Effect processing - Background processor executes effects and chains multi-step workflows
- Type safety - Compile-time verification of valid state transitions
There are five key components:
- State - The possible conditions of your entity, with allowed transitions
- Value - The entity that moves between states
- Effect - Side effects to execute (emails, API calls, etc.)
- Transition - Pure
decide()functions that determine state changes and effects - StateMachine - Validates and applies transitions atomically
stateDiagram-v2
[*] --> Pending
Pending --> Confirmed
Pending --> Cancelled
Confirmed --> Shipped
Confirmed --> Cancelled
Shipped --> Delivered
sealed class OrderState : State<OrderState>() {
data object Pending : OrderState() {
override fun transitions() = setOf(Confirmed, Cancelled)
}
data object Confirmed : OrderState() {
override fun transitions() = setOf(Shipped, Cancelled)
}
data object Shipped : OrderState() {
override fun transitions() = setOf(Delivered)
}
data object Delivered : OrderState()
data object Cancelled : OrderState()
}data class Order(
override val id: String,
override val state: OrderState,
val email: String,
val total: Long
) : Value<String, Order, OrderState> {
override fun update(newState: OrderState) = copy(state = newState)
}sealed class OrderEffect : Effect {
data class SendConfirmationEmail(val orderId: String, val email: String) : OrderEffect()
data class ChargePayment(val orderId: String, val amount: Long) : OrderEffect()
data class NotifyWarehouse(val orderId: String) : OrderEffect()
}Transitions use pure decide() functions that return a Decision:
class ConfirmOrder(private val paymentId: String) : Transition<String, Order, OrderState, OrderEffect>(
from = OrderState.Pending,
to = OrderState.Confirmed
) {
override fun decide(value: Order): Decision<Order, OrderState, OrderEffect> =
Decision.accept(
value = value.update(OrderState.Confirmed),
effects = listOf(
OrderEffect.SendConfirmationEmail(value.id, value.email),
OrderEffect.ChargePayment(value.id, value.total)
)
)
}
class ShipOrder(private val trackingNumber: String) : Transition<String, Order, OrderState, OrderEffect>(
from = OrderState.Confirmed,
to = OrderState.Shipped
) {
override fun decide(value: Order): Decision<Order, OrderState, OrderEffect> =
Decision.accept(
value = value.update(OrderState.Shipped),
effects = listOf(OrderEffect.NotifyWarehouse(value.id))
)
}class OrderEffectHandler(
private val emailService: EmailService,
private val paymentService: PaymentService
) : EffectHandler<String, Order, OrderState, OrderEffect> {
override fun handle(valueId: String, effect: OrderEffect) = when (effect) {
is OrderEffect.SendConfirmationEmail -> runCatching {
emailService.send(effect.email, "Order confirmed!")
EffectOutcome.Completed
}
is OrderEffect.ChargePayment -> runCatching {
val txId = paymentService.charge(effect.amount)
EffectOutcome.TransitionProduced(valueId, PaymentReceived(txId))
}
is OrderEffect.NotifyWarehouse -> runCatching {
warehouseService.notify(valueId)
EffectOutcome.Completed
}
}
}// Create components
val repository = JooqOrderRepository(dsl)
val outbox = JooqOutbox(dsl, OrderEffectSerializer.instance)
val stateMachine = StateMachine(repository)
val effectProcessor = EffectProcessor(outbox, effectHandler, stateMachine, repository::findById)
// Apply a transition
val result = stateMachine.transition(order, ConfirmOrder(paymentId))
// Process effects (run in background)
effectProcessor.processAll()The main state machine implementation:
dependencies {
implementation("app.cash.kfsm:kfsm-v2:<version>")
}Note: The v2 API uses the
app.cash.kfsm.v2package.
Production-ready outbox utilities using jOOQ:
dependencies {
implementation("app.cash.kfsm:kfsm-jooq-v2:<version>")
}Provides:
- JooqOutbox -
SELECT ... FOR UPDATE SKIP LOCKEDfor concurrent processing - PollingEffectProcessor - Background processor with exponential backoff
- MoshiOutboxSerializer - Serialization for sealed class effects
- OutboxSchema - DDL for MySQL and PostgreSQL
Transitions are pure functions, making them easy to test:
@Test
fun `confirm order produces email and payment effects`() {
val order = Order(id = "123", state = Pending, email = "test@example.com", total = 100)
val decision = ConfirmOrder("pay-456").decide(order)
decision.shouldBeInstanceOf<Decision.Accept<*, *, *>>()
decision.value.state shouldBe Confirmed
decision.effects shouldContain OrderEffect.SendConfirmationEmail("123", "test@example.com")
}Effects are stored atomically with state changes:
- At-least-once delivery semantics
- Survives crashes and deployments
- Enables reliable multi-step workflows
Effects can return different outcomes:
| Outcome | Behavior |
|---|---|
TransitionProduced |
Apply follow-up transition, mark processed |
Completed |
Mark processed (terminal effect) |
FailedWithTransition |
Transition to error state, mark processed |
Result.failure() |
Mark failed for retry |
For synchronous-style APIs over async workflows:
val awaitable = AwaitableStateMachine(stateMachine, pendingRequestStore) { state ->
state is OrderState.Delivered || state is OrderState.Cancelled
}
// Suspends until terminal state or timeout
val result = awaitable.transitionAndAwait(order, ConfirmOrder(paymentId), 30.seconds)kFSM validates your state machine:
- Compile-time: Transitions must match declared state paths
- Runtime: Invalid transitions return errors, no side effects executed
- Idempotent: Re-applying a transition to an already-transitioned value is a no-op
Define invariants that must hold for each state:
sealed class OrderState : State<OrderState>() {
data object Confirmed : OrderState() {
override fun transitions() = setOf(Shipped, Cancelled)
override fun invariants() = listOf(
invariant("Order must have payment") { it.paymentId != null }
)
}
}- API docs: https://block.github.io/kfsm
- Changelog: CHANGELOG.md
- Examples: example/ module
- Implementation guide: docs/implementation_guide.md
- Contributing: CONTRIBUTING.md
Note
kFSM uses Hermit.
Hermit ensures that your team, your contributors, and your CI have the same consistent tooling. Here are the installation instructions.
Activate Hermit either
by enabling the shell hooks (one-time only, recommended) or
manually sourcing the env with . ./bin/activate-hermit.
gradle build