Quantcast
Channel: 8th Light Insights
Viewing all articles
Browse latest Browse all 207

ExState: Database-backed statecharts for Elixir and Ecto

$
0
0

On a recent project, working with Elixir, Ecto, and Phoenix, we were encountering difficulty modeling and managing a complex business workflow. This is a fairly typical problem to have on any project—there have been many times I've known that I'm building an implicit finite state machine but don't take the time to make it explicit—but here it was becoming particularly unmanageable. It became clear that we needed to implement a more formal state machine to encode this logic.

Eventually, we implemented a solution that allows us to easily model these workflows in code, in a simple enough way that it also facilitates talking through these workflows and statecharts with non-technical project stakeholders. Read on to learn more about ExState, or try it out to see if it's useful to you.

The Problem

Tracking any complex process or workflow inevitably requires conditional logic based on the state of entities involved in the workflow. This can easily lead to a proliferation of fields and states used to track this process. Take an example Order process, for instance:

defmoduleOrderdouseEcto.Schemaschema"orders"dofield:created_at,:utc_datetimefield:confirmed,:booleanfield:confirmed_at,:utc_datetimefield:shipped,:booleanfield:shipped_at,:utc_datetimefield:shipment_status,:stringfield:cancelled,:booleanfield:cancelled_at,:utc_datetimehas_many:items,Itemendend

In the above schema, we have four fields related to tracking the state of the order. These fields can change independently and it's unclear how they should relate to one another. Is %{confirmed: false, shipped: true, cancelled: true} a valid state? Probably not, but it would require a case by case validation to enforce that.

The order workflow and state becomes much easier to understand and validate if we collapse those fields into one state field:

defmoduleOrderdouseEcto.Schemaschema"orders"dofield:state,:stringfield:created_at,:utc_datetimefield:confirmed_at,:utc_datetimefield:shipped_at,:utc_datetimefield:cancelled_at,:utc_datetimehas_many:items,ItemendenddefmoduleOrdersdoaliasEcto.Multidefcancel(order)doiforder.statein["pending","confirmed"]doorder|>Order.changeset(%{status:"cancelled",cancelled_at:DateTime.utc_now()})|>Repo.update()else{:error,"can't cancel this order"}endenddefship(order)doiforder.state=="confirmed"doMulti.new()|>Multi.update(:order,Order.changeset(order,%{status:"shipped",shipped_at:DateTime.utc_now()}))|>Multi.run(:ship,fn_repo,%{order:order}->Shipments.ship(order)end)|>Repo.transaction()else{:error,"order must be in the confirmed state"}endendend

We can then use the states ["created", "confirmed", "shipped", "delivered", "cancelled"] to represent our order workflow. But we still need to replace the shipment_state field and to express sub-states of the "shipped" state. We're also still required to do a lot of explicit checking of the current state before taking any action.

Statecharts

A lot of the workflows and processes we build in our software can be modeled as finite state machines. The system, or an entity in the system, is in one given state at any time. In that state, the system handles events and transitions to a next state with logic that is dependent upon the current state.

Elixir and Erlang already provide tools for implementing state machine behavior through gen_statem and its predecessor gen_fsm. These are great for implementing stateful processes, but aren't well-suited for state that's stored in a database with a request and response lifecycle.

Our order process resembles a finite state machine, but with a few additional details. In the "shipped" state, we want to track multiple shipment_status states. So the "shipped" state itself represents multiple states:

[{"shipped","pending"},{"shipped","in_progress"},{"shipped","arriving_soon"},{"shipped","out_for_delivery"}]

Rather than a simple finite state machine, we have a hierarchical state machine, with certain states like "shipped" representing a lower level of states and transitions.

ExState

ExState was designed to simplify the definition of states, events, and transitions for simple or hierarchical state machines in Elixir. ExState can also persist these states and metadata to the database through Ecto for use in web apps or other database-backed applications.

Using ExState begins with a definition module. ExState.Definition defines the workflow macro, which builds a data representation of the state chart and binds it to the module and the associated functions for transitioning the workflow with a subject and other context data.

defmoduleOrderWorkflowdouseExState.Definitionworkflow"order"dosubject:order,Orderinitial_state:pendingstate:preparingdoinitial_state:pendingon:cancel,:cancelledstate:pendingdostep:confirmon_completed:confirm,:confirmedendstate:confirmeddostep:shipon_completed:ship,{:<,:shipped}endendstate:shippeddoinitial_state:in_transiton_entry:update_shipped_atstate:in_transitdoon:arriving_soon,:arriving_soonendstate:arriving_soondoon:out_for_delivery,:out_for_deliveryendstate:out_for_deliverydoon:delivered,{:<,:delivered}endendstate:delivereddofinalendstate:cancelleddoon_entry:update_cancelled_atfinalendenddefupdate_shipped_at(%{order:order})update_timestamp(order,:shipped_at)enddefupdate_cancelled_at(%{order:order})update_timestamp(order,:cancelled_at)enddefpupdate_timestamp(order,timestamp)doorder|>Order.changeset(%{timestamp=>DateTime.utc_now()})|>Repo.update()endend

The subject of the workflow is an Ecto model that defines a workflow association using the has_workflow macro.

defmoduleOrderdouseEcto.SchemauseExState.Ecto.Subjectschema"orders"dofield:created_at,:utc_datetimefield:confirmed_at,:utc_datetimefield:shipped_at,:utc_datetimefield:cancelled_at,:utc_datetimehas_workflowOrderWorkflowhas_many:items,Itemendend

The associated context module can then update the model and its workflow through ExState:

defmoduleOrdersdoaliasEcto.Multi@doc"""  Create an order and workflow in a Ecto.Multi transaction."""defcreate_order(attrs)do{:ok,%{order:order,workflow:workflow}}=Multi.new()|>Multi.create(:order,Order.new(attrs))|>ExState.Multi.create(:order)|>Repo.transaction()end@doc"""  Load, complete step, and persist."""defconfirm_order(order)doexecution=ExState.load(order){:ok,execution}=ExState.Execution.complete(execution,:confirm){:ok,order}=ExState.persist(execution)end@doc"""  Use `ExState.event/3` convenience function to load, transition, and persist."""defcancel_order(order)do{:ok,order}=ExState.event(order,:cancelled)end@doc"""  Load, complete, ship, and persist in a transaction."""defship(order)do{:ok,%{ship:shipped_order,shipment:shipment}}=Multi.new()|>ExState.Ecto.Multi.complete(order,:ship)|>Multi.run(:shipment,fn_repo,%{order:order}->Shipments.ship(order)end)|>Repo.transaction()enddefarriving_soon(order)do{:ok,order}=ExState.transition(order,:arriving_soon)endend

States

States are defined in four main forms.

Atomic States

Atomic states have no child states. The following three states are atomic states:

workflow"example"doinitial_state:atomic_astate:atomic_adoon:next,:atomic_bon:done,:atomic_doneendstate:atomic_bdoon:back,:atomic_aendstate:atomic_doneend

Compound States

Compound states contain child states and specify an initial state (one of the child states). The :preparing and :shipped states in the order workflow are compound states.

Final States

A final state represents a state of either a child state or the entire workflow where the state should be considered "complete."

workflow"example"doinitial_state:astate:adoon_final:bstate:onedoon:did_one,:twoendstate:twodofinalendendstate:bend

Transient States

Transient states are used for dynamically resolving the next state based on conditions defined in the definition module. The transient state immediately handles a "null" event, :_, and transitions to the first state in the list that a guard allows.

defmoduleSetupWorkflowdouseExState.Definitionworkflow"setup"doinitial_state:unknownstate:unknowndoon:_,[:accept_terms,:working]endstate:accept_termsstate:workingenddefguard_transition(:unknown,:accept_terms,context)doifcontext.user.has_accepted_terms?do:okelse{:error,:accepted}endendend

Actions

Actions are useful for triggering side effects on certain events. Actions are called as functions on the definition module, and should return :error | {:error, reason} if transactional execution should be halted when an action cannot be completed. Actions can also return an {:updated, subject} tuple to replace the updated subject in the execution state.

defmoduleExampleWorkflowsouseExState.Definitionworkflow"example"dostate:workingdoon_entry:send_entry_emailon:cancel,:canceled,actions:[:send_cancelled_email]on_final:done,actions:[:send_final_email]endenddefsend_entry_email(_context),do::okdefsend_final_email(_context),do::okdefsend_cancelled_email(context)do{:updated,Map.put(context,:document,%{document|cancellation_email_sent_at:DateTime.utc_now()})}endend

Guards

Guards can ensure that transitions are only made when certain conditions are met. The guard_transition/3 function on the definition module will be called during workflow execution with the current state, next state, and the context as arguments. A guard returns :ok to allow the transition, or {:error, reason to prevent the transition.

ExState doesn't rescue exceptions in guards or actions, so exception handling behavior is dependent upon the current database transaction context, if any.

Steps

Steps are a convenient way to collapse an implicitly linear set of states and events into an explicitly ordered list of events. This is useful for exposing required steps to UI components or API consumers. Steps can also be ignored through the use_step/2 callback.

state:workingdoinitial_state:adding_nameon_final:reviewingstate:adding_namedoon:name_added,:adding_emailendstate:adding_emaildoon:email_added,:adding_phone_numberendstate:adding_phone_numberdoon:phone_number_added,:confirmingendstate:confirmingdoon:confirmed,:doneendstate:donedofinalendendstate:reviewing

The above workflow could be rewritten using four explicit steps:

defmoduleSetupWorkflowdouseExState.Definitionworkflow"setup"dosubject:account,Accountinitial_state:workingstate:workingdostep:add_namestep:add_emailstep:add_phone_numberstep:confirmon_completed:confirm,:reviewingendstate:reviewingenddefuse_step?(:add_phone_number,context)docontext.account.phone_number_required?enddefuse_step?(_,_),do:trueend
{:ok,account}=ExState.create(account){:ok,account}=ExState.complete(account,:add_name){:ok,account}=ExState.complete(account,:add_email){:error,_reason}=ExState.complete(account,:confirm){:ok,account}=ExState.complete(account,:add_phone_number){:ok,%{workflow:%{state:"reviewing"}}}=ExState.complete(account,:confirm)

Decisions

Similar to steps, decisions are required events that transition based on the value provided in the decision.

defmoduleReviewWorkflowdouseExState.Definitionworkflow"review"doinitial_state:ratingstate:ratingdostep:rateon_decision:rate,:good,:doneon_decision:rate,:bad,:feedbackendstate:feedbackdostep:provide_feedbackon_completed:provide_feedback,:doneendstate:donedofinalendendend
{:ok,review}=ExState.create(review){:ok,review}=ExState.decision(review,:rate,:good)

Querying State

You'll likely want to use the workflow state in queries as well. ExState has some builtin queries to help with this:

importExState.Ecto.Queryshipped_orders=Order|>where_any_state(:shipped)|>Repo.all()in_transit_orders=Order|>where_state([:shipped,:in_transit])|>Repo.all()confirmed_orders=Order|>where_step_complete(:confirm)|>Repo.all()

Without Ecto

ExState can be used without persisting the workflow to the database, either for testing or in memory use cases. Just use the functions defined on the workflow definition module itself:

%{state:%{name:"delivered"},context:%{order:order}}=execution=OrderWorkflow.new(%{order:order})|>OrderWorkflow.transition_maybe({:completed,:confirm})|>OrderWorkflow.transition_maybe({:completed,:ship})|>OrderWorkflow.transition_maybe(:out_for_delivery)|>OrderWorkflow.transition_maybe(:arriving_soon)|>OrderWorkflow.transition_maybe(:delivered){:error,_reason,execution}=OrderWorkflow.transition(execution,:cancel)

Notes

Try ExState on hex, read the docs, or check out the code on GitHub for additional documentation and examples.

Credit to David Khourshid and xstate for excellent documentation, examples, and API inspiration on this topic.


Viewing all articles
Browse latest Browse all 207

Trending Articles