Workflow (State Machine)
Overview
The Genesis server state machine is used to define workflow for application's table entities. State machines work in conjunction with Event Handlers to ensure records for the entity follow the correct workflow at all times.
To give an example, you may be building an Order Management System, and need to define the lifecycle of your Order's. You may have various states for your order defined in an ENUM:
ORDER_STATUS:
- NEW
- ASSIGNED
- PARTIALLY_FILLED
- FULLY_FILLED
- COMPLETED
- CANCELLED
There are typically two aspects of related system logic to define in your state machine:
- Transitions ; Define what the valid transitions between the states are. Building on our example, you may need to configure that order must be
ASSIGNED
before it is moved toPARTIALLY_FILLED
. Also,CANCELLED
is an end state and thus has no valid transitions to another state. - State Validation ; Validate that all the required fields are set to transition to a given state. For example, an Order must have
ASSIGNED_TO
set to a valid user in the system to transition toASSIGNED
.
Example configuration
State Machines can be written in-line within a GPAL event handler, this is the recommended approach when using GPAL in your application.
A StateMachine API is also available, which can be used if you decide to write your event handlers using the event handler API. See further down for examples and docs.
The following example of a State Machine defines five events that control the transition of orders from one state to another.
eventHandler {
stateMachine(ORDER.ORDER_STATUS) {
insertEvent { //This generates an EVENT called `EVENT_ORDER_INSERT`
initialStates(ORDER_STATUS.NEW, ORDER_STATUS.ASSIGNED) //You can only set the ORDER_STATUS to these values on an insert.
excludedFields {
ENTERED_BY
ENTERED_TIME
MODIFIED_BY
MODIFIED_TIME
}
//onEvent can be used to update the event details prior to onValidate and onCommit being run
//event.withDetails is an optional helpful function to edit the event record without prefixing everything with event.details
onEvent { event ->
event.withDetails {
enteredBy = event.userName
enteredTime = now()
}
}
//This defines the resulting insert event's onValidate function.
onValidate { order ->
verifyOnly { order hasField ORDER.PRICE }
verifyOnly { order hasField ORDER.QUANTITY greaterThan 0 }
if(order.orderState == OrderStatus.ASSIGNED) {
if(order.assignedTo == null) {
return onValidate@nack("Order must be assigned to set status of ASSIGNED")
}
}
}
}
modifyEvent {
mutableStates(OrderStatus.NEW, OrderStatus.ASSIGNED) //You can only modify an order when it is in state NEW or ASSIGNED
excludedFields {
ENTERED_BY
ENTERED_TIME
MODIFIED_BY
MODIFIED_TIME
}
//As this is a modify event and we know the event uses the table object, both event and the retrieved record are available in onEvent on modifyEvent and transactionEvents
onEvent { event, order ->
order.modifiedBy = event.userName
order.modifiedTime = now()
}
onValidate { order ->
verifyOnly { order hasField ORDER.PRICE }
verifyOnly { order hasField ORDER.QUANTITY greaterThan 0 }
if(order.orderState == OrderStatus.ASSIGNED) {
if(order.assignedTo == null) {
return onValidate@nack("Order must be assigned to someone to set status of ASSIGNED")
}
}
}
}
//An order can only go to ASSIGNED from NEW
transitionEvent(OrderStatus.ASSIGNED) {
fromStates(OrderStatus.NEW)
onEvent { event, order ->
order.modifiedBy = event.userName
order.modifiedTime = now()
}
onValidate { order ->
if(order.orderState == OrderStatus.ASSIGNED) {
if(order.assignedTo == null) {
return onValidate@nack("Order must be assigned to someone to set status of ASSIGNED")
}
}
}
}
//An order can only go to PARTIALLY_FILLED from ASSIGNED
transitionEvent(OrderStatus.PARTIALLY_FILLED) {
fromStates(OrderStatus.ASSIGNED)
onEvent { event, order ->
order.modifiedBy = event.userName
order.modifiedTime = now()
}
onValidate { order ->
require(order.quantityFilled > 0) { "Order must have some quantity filled to be set as PARTIALLY_FILLED" }
}
}
//An order can only go to FULLY_FILLED from PARTIALLY_FILLED
transitionEvent(OrderStatus.FULLY_FILLED) {
fromStates(OrderStatus.PARTIALLY_FILLED)
onEvent { event, order ->
order.modifiedBy = event.userName
order.modifiedTime = now()
}
onValidate { order ->
require(order.quantityFilled == order.quantity) { "Order must have quantity filled to the full order quantity to be set as FULLY_FILLED" }
}
}
//An order can go to COMPLETED from ASSIGNED, PARTIALLY_FILLED, FULLY_FILLED
transitionEvent(OrderStatus.COMPLETED) {
fromStates(OrderStatus.ASSIGNED, OrderStatus.PARTIALLY_FILLED, OrderStatus.FULLY_FILLED )
onEvent { event, order ->
order.modifiedBy = event.userName
order.modifiedTime = now()
}
}
//An order can go to CANCELLED from ASSIGNED, PARTIALLY_FILLED, FULLY_FILLED
transitionEvent(OrderStatus.CANCELLED) {
fromStates(OrderStatus.ASSIGNED, OrderStatus.PARTIALLY_FILLED, OrderStatus.FULLY_FILLED)
onEvent { event, order ->
order.modifiedBy = event.userName
order.modifiedTime = now()
}
}
}
}
Configuration options
State Machines can be written in-line within a GPAL event handler, else written in Kotlin and injected for use. Configuration options of both are detailed here.
stateMachine
Takes two parameters:
tableField
; which is the field of a table which holds the state.transactional
; optional boolean parameter (default is true) which functions the same as definingtransactional
on a standard eventHandler`
eventHandler {
stateMachine(tableField = ORDER.ORDER_STATUS, transactional = true) {
...
}
}
insertEvent
Define an event on this state machine for inserting the entity. The resulting eventHandler will be named EVENT_<table_entity_name>_INSERT
.
In the below example the event would be called EVENT_ORDER_INSERT
.
eventHandler {
stateMachine(tableField = ORDER.ORDER_STATUS, transactional = true) {
insertEvent {
...
}
...
}
}
initialStates
The valid values that can be set on the tableField
on an insertEvent
...
insertEvent {
initialStates(ORDER_STATUS.NEW, ORDER_STATUS.ASSIGNED)
...
}
}
}
excludedFields
Removes fields defined on the table from the event. These fields should only be set by the events and not the client.
insertEvent {
...
excludedFields {
ENTERED_BY
ENTERED_TIME
MODIFIED_BY
MODIFIED_TIME
}
...
}
onEvent
onEvent
can be used to update the event details prior to onValidate and onCommit being run. It provides event information, which can be manipulated before onValidate
and onCommit
blocks are run.
insertEvent {
...
onEvent { event ->
event.withDetails {
enteredBy = event.userName
enteredTime = now()
}
}
...
}
onValidate
Validate the inbound event details here. See the event handler onValidate
documentation for the full usage of onValidate
.
One difference from regular eventHandler onValidate
block is that we don't have to return an ack
, it will be implicitly returned where no exception is hit, or nack
returned.
insertEvent {
...
onValidate { order ->
verifyOnly { order hasField ORDER.PRICE }
verifyOnly { order hasField ORDER.QUANTITY greaterThan 0 }
if(order.orderState == OrderStatus.ASSIGNED) {
if(order.assignedTo == null) {
return onValidate@nack("Order must be assigned to set status of ASSIGNED")
}
}
}
...
}
onCommit
Commit the inbound event details here. See the event handler onCommit
documentation for the full usage of onCommit
.
This block is optional.
There are a couple of differences from regular eventHandler onCommit
block.
- We don't have to write the main object to the DB. This will also happen implicitly
- We don't have to return an
ack
, it will be implicitly returned where no exception is hit, ornack
returned.
insertEvent {
...
onCommit { order ->
//You can perform other functionality prior to committing the object here.
}
...
}
modifyEvent
Define an event on this state machine for modifying the entity. The resulting eventHandler will be named EVENT_<table_entity_name>__MODIFY
.
In the below example the event would be called EVENT_ORDER_MODIFY
.
eventHandler {
stateMachine(tableField = ORDER.ORDER_STATUS, transactional = true) {
...
modifyEvent {
...
}
...
}
}
mutableStates
The valid values that the table record can have set on the tableField
on an modifyEvent
.
For the example below, you can only modify an order when it has a status of NEW or ASSIGNED
...
modifyEvent {
mutableStates(OrderStatus.NEW, OrderStatus.ASSIGNED)
...
}
}
}
modifyEvent
can also define the following same blocks as insertEvent
. Click to see their descriptions for usage details:
excludedFields
onEvent
: One exception on themodifyEvent
, per the example configuration as this the object is already in the DB and we know the event uses the table object, both event and the retrieved record are available inonEvent
onValidate
onCommit
transitionEvent
Specifying a transitionEvent
will generate an event specifically for transitioning the status. These events define the possible transitions of the ORDER_STATUS
field. The resulting eventHandler will be named EVENT_<table_entity_name>__<status being transitioned to>
.
In the below example the event would be called EVENT_ORDER_ASSIGNED
.
transitionEvent(OrderStatus.ASSIGNED) {
...
}
fromStates
Defines the states we can transition from, to the state defined in the transitionEvent
parameter. Use comma separation to define multiple states.
In the example below the Order.OrderStatus must be NEW for this event to validate.
transitionEvent(OrderStatus.ASSIGNED) {
fromStates(OrderStatus.NEW)
...
}
transitionEvent
can also define the following same blocks as insertEvent
. Click to see their descriptions for usage details:
excludedFields
onEvent
: One exception on thetransitionEvent
, per the example configuration as this the object is already in the DB and we know the event uses the table object, both event and the retrieved record are available inonEvent
onValidate
onCommit
Client API
The State Machine is used to configure workflow states and works hand in hand with event handlers. There is no Client API specifically for a State Machine. Use the event handler client API to interact with your events.
Runtime configuration
In-line state Machines are defined as Event Handlers in your application's *-eventhandler.kts
file and you shouldn't need to do any more.
If you have defined your state-machine in kotlin, make sure the event handler definition in processes.xml
includes the .jar
file containing the state machine in <classpath>
.
See runtime configuration - processes for further details.
Performance
If better database response performance is required, you may also consider added a cache to the process. See runtime configuration - process cache for more details.
Integration testing
A State Machine is just another layer of an Event Handler, the test configuration is same as for Event Handlers. You should add further tests to test your transition workflow logic works as anticipated.
See the event handlers testing section for more details.
StateMachine API
The Genesis Application Platform also offers an API for writing your state machine in kotlin. This approach is best used when you have written your event handlers using the event handler API
Example code
The following is an example State Machine written in kotlin. The functionality example mirrors the in-line GPAL example configuration described previously.
@Module
class OrderStateMachine @Inject constructor(
db: AsyncEntityDb
) {
private val internalState: StateMachine<Order, OrderStatus, OrderEffect> = db.stateMachineBuilder {
// Read ORDER_STATUS from Order table
readState { orderStatus }
// Defines how the status NEW is handled
state(OrderStatus.NEW) {
// Allows the status to change
isMutable = true
// State machine will accept the specified state on creation of order
initialState(OrderEffect.New) {
onValidate { order ->
verifyOnly { order hasField ORDER.PRICE }
verifyOnly { order hasField ORDER.QUANTITY greaterThan 0 }
}
}
onCommit { order ->
event.withDetails {
enteredBy = event.userName
enteredTime = now()
}
}
// Allowed transitions are from NEW to ASSIGNED
transition(OrderStatus.ASSIGNED, OrderEffect.Assigned)
}
state(OrderStatus.ASSIGNED) {
// Allows the status to change
isMutable = true
//State machine will accept the specified state on creation of order
//Here we have our onValidate outside and will always run regardless of this being an insert or transition.
// initialState.onValidate on the NEW as it will only be on an insert. Assigned can be transitioned to.
initialState(OrderEffect.Assigned)
onValidate { order ->
verifyOnly { order hasField ORDER.PRICE }
verifyOnly { order hasField ORDER.QUANTITY greaterThan 0 }
if(order.orderState == OrderStatus.ASSIGNED) {
if(order.assignedTo == null) {
return onValidate@nack("Order must be assigned to set status of ASSIGNED")
}
}
}
onCommit { order ->
event.withDetails {
enteredBy = event.userName
enteredTime = now()
}
}
// Allowed transitions from ASSIGNED
transition(OrderStatus.PARTIALLY_FILLED, OrderEffect.PartiallyFilled)
transition(OrderStatus.COMPLETED, OrderEffect.Completed)
transition(OrderStatus.CANCELLED, OrderEffect.Cancelled)
}
state(OrderStatus.PARTIALLY_FILLED) {
isMutable = true
onValidate { order ->
require(order.quantityFilled > 0) { "Order must have some quantity filled to be set as PARTIALLY_FILLED" }
}
//Here we are using the onCommit, there is no onEvent when using the state machine API
onCommit { order ->
event.withDetails {
modifiedBy = event.userName
modifiedTime = now()
}
}
transition(OrderStatus.FULLY_FILLED, OrderEffect.Assigned)
transition(OrderStatus.COMPLETED, OrderEffect.Completed)
transition(OrderStatus.CANCELLED, OrderEffect.Cancelled)
}
state(OrderStatus.FULLY_FILLED) {
isMutable = true
onValidate { order ->
require(order.quantityFilled == order.quantity) { "Order must have quantity filled to the full order quantity to be set as FULLY_FILLED" }
}
onCommit { order ->
event.withDetails {
modifiedBy = event.userName
modifiedTime = now()
}
}
transition(OrderStatus.COMPLETED, OrderEffect.Completed)
transition(OrderStatus.CANCELLED, OrderEffect.Cancelled)
}
state(OrderStatus.COMPLETED) {
isMutable = false
onCommit { order ->
event.withDetails {
modifiedBy = event.userName
modifiedTime = now()
}
}
}
state(OrderStatus.CANCELLED) {
isMutable = false
onCommit { order ->
event.withDetails {
modifiedBy = event.userName
modifiedTime = now()
}
}
}
}
}
//When using the StateMachine API we also have SideEffects, which can be subscribed to and trigger actions if required.
sealed class OrderEffect {
object New : OrderEffect()
object Assigned : OrderEffect()
object PartiallyFilled : OrderEffect()
object FullyFilled : OrderEffect()
object Completed : OrderEffect()
object Cancelled : OrderEffect()
}
API
You can build a State Machine using this interface by providing:
- the entity of the State Machine. In our example, it is called
Order
. - the state of the entity field to manage; this field needs to be ENUM field. In our example, it is called
OrderStatus
. - the side effect of a state change, which is something you can subscribe to when the state changes to trigger further actions. In our example, it is called
OrderEffect
and you can see we add it in-line in the same file we create the StateMachine module.
The following is an example State Machine written in kotlin. This would be injected into your event handler as described in the configuration options section.
This option is only recommended when you are using the event handler API for your event handle.
@Module
class OrderStateMachine @Inject constructor(
db: AsyncEntityDb
) {
private val internalState: StateMachine<Order, OrderStatus, OrderEffect> = db.stateMachineBuilder {
...
}
}
sealed class OrderEffect {
object New : OrderEffect()
object Assigned : OrderEffect()
object PartiallyFilled : OrderEffect()
object FullyFilled : OrderEffect()
object Completed : OrderEffect()
object Cancelled : OrderEffect()
}
The following functions are available:
readState
fun readState(init: T.() -> S)
This specifies which state to read from table.
state
fun state(state: S, stateHandler: StateBuilder<T, S, E>.() -> Unit)
This is the method where you define how to handle the state of the table. Handling of the state is managed by StateBuilder
, which is explained in detail below.
StateBuilder
Signature | Description | |
---|---|---|
isMutable | var isMutable: Boolean | When set to false , any modification to the entity while in this state will be rejected |
initialState | fun initialState(sideEffect: E, transitionBuilder: TransitionBuilderInit<T>? = null) | The State Machine will accept the specified state on creation |
initialState | fun initialState(sideEffect: E,priority: Int,transitionBuilder: TransitionBuilderInit<T>? = null) | The State Machine will accept the specified state on creation and you can specify priority for this transition |
initialStateWithContext | fun <C : Any> initialStateWithContext(sideEffect: E,transitionBuilder: ContextTransitionBuilder<T, C>.() -> Unit,) | The State Machine will accept the current state on creation. The validation will share a context. |
initialStateWithContext | fun <C : Any> initialStateWithContext(sideEffect: E,priority: Int,transitionBuilder: ContextTransitionBuilder<T, C>.() -> Unit,) | The State Machine will accept the current state on creation and you can specify priority for this transition. The validation will share a context. |
transition | fun transition(newState: S,sideEffect: E,transitionBuilder: TransitionBuilderInit<T>? = null,) | The State Machine will transition from the current state to [newState] |
transition | fun transition(newState: S,sideEffect: E,priority: Int,transitionBuilder: TransitionBuilderInit<T>? = null,) | The State Machine will transition from the current state to [newState], and you can specify priority for this transition |
transitionWithContext | fun <C : Any> transitionWithContext(newState: S,sideEffect: E,transitionBuilder: ContextTransitionBuilder<T, C>.() -> Unit,) | The State Machine will transition from the current state to [newState] |
transitionWithContext | fun <C : Any> transitionWithContext(newState: S,sideEffect: E,priority: Int,transitionBuilder: ContextTransitionBuilder<T, C>.() -> Unit,) | The State Machine will transition from the current state to [newState], and you can specify priority for this transition. The validation will share a context |
StateMachine
State Machine properties and functions:
Signature | Description | |
---|---|---|
sideEffects | val sideEffects: Set<E> | Provides all possible side effects |
subscribe | fun subscribe(): Flow<Transition<T, S, E>> | Subscribers will be notified of state transitions |
highestPriorityTransition | suspend fun highestPriorityTransition(value: T,): ValidationResult<S>? | Given the table name, this gives the highest priority transition of the table if present or null |
currentState | suspend fun currentState(value: T): S? | Reads the current state [S] of [value] [T] from the database |
create | suspend fun create(value: T): Transition<T, S, E> | Creates a new entity in the database. |
update | suspend fun update(value: T): Transition<T, S, E>? | Will validate and write [value] to the database. Returns a [Transition] if the update caused a transition, else null and throws IllegalArgumentException if the value is not valid |
update | suspend fun update(uniqueEntityIndex: UniqueEntityIndex<T, *>,updateEntity: suspend (entity: T, transaction: AsyncMultiEntityReadWriteGenericSupport) -> Unit,): Transition<T, S, E>? | Accepts a [UniqueEntityIndex] and an update lambda, and validates and writes [value] to the database. Returns a [Transition] if the update caused a transition, else null and throws IllegalArgumentException if the value is not valid, or if the item doesn't exist in the database. |
updateIfExists | suspend fun updateIfExists(uniqueEntityIndex: UniqueEntityIndex<T, *>,updateEntity: suspend (entity: T, transaction: AsyncMultiEntityReadWriteGenericSupport) -> Unit,): Transition<T, S, E>? | Accepts a [UniqueEntityIndex] and an update lambda and validates and writes [value] to the database. Returns a [Transition] if the update caused a transition, else null and throws IllegalArgumentException if the value is not valid |
validate | suspend fun validate(value: T,newState: S,): ValidationResult<S> | Validates if [value] can successfully transition to [newState] |
withTransaction | suspend fun <O : Any?> withTransaction(transaction: AsyncMultiEntityReadWriteGenericSupport,handling: suspend StateMachine<T, S, E>.() -> O,): O | Will take a transaction and will handle all State Machine calls within [handling] within the provided transaction |
withTransaction | suspend fun <O : Any?> withTransaction(handling: suspend StateMachine<T, S, E>.() -> O,): O | Will create a transaction and will handle all State Machine calls within [handling] within the provided transaction |
Here is an example of a State Machine, where you can see properties and functions in action:
suspend fun insert(trade: Trade): Transition<Trade, TradeStatus, TradeEffect> = internalState.create(trade)
suspend fun modify(tradeId: String, modify: suspend (Trade) -> Unit): Transition<Trade, TradeStatus, TradeEffect>? =
internalState.update(Trade.ById(tradeId)) { trade, _ -> modify(trade) }
suspend fun modify(trade: Trade): Transition<Trade, TradeStatus, TradeEffect>? = internalState.update(trade)
Injecting State Machine
Event Handler API
@Module
class EventCompanyHandlerAsync @Inject constructor(
private val entityDb: AsyncEntityDb,
private val orderMachine: OrderStateMachine
) : AsyncEventHandler<Company, EventReply> {
override suspend fun process(message: Event<Company>): EventReply {
val company = message.details
// custom code block..
return EventReply.EventAck()
}
}
Find more details on injection on our Custom Components page
GPAL Event Handler file
It is recommended to use in-line state machines as described in example configuration. However, if needed you can also inject into a GPAL event handler per the following example.
Where adopting this, you should use stateMachine
handle instead of the entityDb
, to commit database changes to ensure the state machine logic is triggered.
eventHandler {
val stateMachine = inject<OrderStateMachine>()
eventHandler<Order>(name = "ORDER_INSERT", transactional = true) {
permissioning {
permissionCodes = listOf("ORDERR")
auth(mapName = "ENTITY_VISIBILITY") {
authKey {
key(data.counterpartyId)
}
}
}
onValidate { event ->
val message = event.details
verify {
entityDb hasEntry Counterparty.ById(message.counterpartyId)
entityDb hasEntry Instrument.ById(message.instrumentId)
}
ack()
}
onCommit { event ->
val order = event.details
order.enteredBy = event.userName
stateMachine.insert(entityDb, order)
ack()
}
}
}