When To Use A State Machine

I recently implemented a state machine at work and it worked to perfection. The state machine saved us multiple hours and rescued us from redundant, error-prone code. But state machines feel over-complicated for most situations, so what exactly is the tipping point to make a decision?

State_machine_diagram

TL;DR

  • Deciding when to implement a state machine can be tricky. However, status of state fields, especially when coupled with state transition actions/callbacks, are usually good indicators
  • State machines help provide guidelines for "golden path" state transitions and help document your code
  • Server-side state machines provide reusable logic to front-end clients and APIs via serialization of allowed transitions
    • this last point is important because duplicating server-side logic in javascript is error prone and cumbersome


Full Feature

The "smell" for state machines is a status or state field often tied to actions based on state transitions. For example, if you want to send an email when a subscription expires, a state machine might be of use to you. However, deciding when to use one can be tough. I try to practice a "YAGNI" (You Aren't Gonna Need It) approach to solving technical problems - only developing a solution for the problem at hand - and implementing a state machine definitely feels heavy-handed for most situations. In this particular situation, however, it became apparent a state machine was a good option.

Our problem is a relatively simple one. We have a trade domain model that after matching needs to transition from funded to repaid over time with intermittent steps along the way. After certain state transitions, emails need to be sent to the parties of the trade.

In the first market that traded, the state transitions were handled by background workers and communication with the custodial bank (SFTP file server uploads / downloads... ugh). For example, we would upload wiring instructions to the bank's SFTP server and transition the trade to funding_sent. Then, after the bank ack'ed back with confirmation we transitioned to funded. We would then send any necessary emails to the parties of the trade. All the callback logic was implemented in the background workers that were were kicked off from cron jobs.



Because there was only one market and all state transitions were handled in background workers no state machine was necessary at this point. But then a new feature came up - a brand new market.


This new market would require borrowers to pledge collateral when borrowing from lenders. The borrower's collateral bank would upload collateral (CUSIPs of treasury bonds) and all movement of money would be handled off-line. We call this the "Collateral Market".


So, we add a few market-specific state transitions and the new workflow became:

But still, no state machine. We implemented the state transition logic in controllers, view code, user-ability code, etc. effectively delegating the responsibility of a state machine to the MVC framework. At this point you could argue the value of a state machine, but the benefit of rolling our own on a short timeline, or including a rather large library wasn't worth it... yet.



But then the client wanted a new feature - a brand new market (you get the point).


This new market was to be completely handled "off-line". Wiring instructions would be managed by banks and sent directly to the parties of the trade. The banks themselves would be responsible for transitioning the states of the trade. This new market, let's call it "the offline market", would run in parallel with the custodial bank and collateral markets. At first glance, this might seem like a pretty straightforward implementation given the work in existing markets, but let's revisit our initial solution.



* background workers housing email and state transition logic
* controller actions for handling state transitions in the collateral market
* "loose" guards, and no enforced restrictions on state transitions across all markets
* serialized user-defined abilities partly responsible for javascript logic in rendering buttons/actions
* TradeEmail logic responsible for checking what state the trade is in in order to send the correct email



We have a problem here. This new market would require us to "stuff" more logic into existing conventions - controllers, serializers, abilities, and the view. Our logic is scattered in numerous locations and difficult to extend. God forbid there comes yet another market!

So we implemented a state machine using a third party library, AASM - ruby's go-to state machine library. And the results were fantastic. We were able to define each state transition and pull all the floating logic into the state machine. Best of all, we were able to serialize the available state transitions for each trade in JSON and send that to angular where our Trade objects could determine what transitions were available to them.



Here's a look at the Trade state machine. Notice how emails are sent after state transitions and the guard logic depending on what market the trade occurs in:

  aasm column: :status, enum: true do
    state :matched, initial: true
    state :collateral_pledged
    state :collateral_approved
    state :funding_sent
    state :funded
    state :funding_failed
    state :matured
    state :repayment_sent
    state :repaid
    state :busted
    state :repayment_failed

    event :bust do
      transitions to: :busted # from ANY
    end

    event :pledge_collateral, guards: [:collateralized?] do
      transitions :from => [:matched, :collateral_pledged], :to => :collateral_pledged
      after do
        # puts "Oh hai"
      end
    end

    event :approve_collateral, guards: [:collateralized?] do
      transitions :from => [:collateral_pledged], :to => :collateral_approved
      after do
        TradeEmail.new(self).deliver_collateral_approved_notifications
      end
    end

    event :send_funding, guards: Proc.new { not collateralized? } do
      transitions :from => [:matched], :to => :funding_sent
      after do
        TradeEmail.new(self).deliver_funding_sent_notifications
      end
    end

    event :fund do
      transitions :from => [:funding_sent, :collateral_approved], :to => :funded
      after do
        TradeFeeService.create_fees!(self) if (collateralized? || self_reported?)
      end
    end

    event :fail_funding, guards: [:manual_market?] do
      transitions :from => [:matched, :funding_sent, :collateral_pledged, :collateral_approved], :to => :funding_failed
      after do
        # puts "Oh hai"
      end
    end

    event :mature do
      transitions :from => [:funded], :to => :matured
      after do
        TradeEmail.new(self).deliver_maturity_notifications if (collateralized? || self_reported?)
      end
    end

    event :send_repayment do
      transitions :from => [:matured], :to => :repayment_sent, guard: Proc.new { not collateralized? }
      after do
        TradeEmail.new(self).deliver_repayment_sent_notifications
      end
    end

    event :repay do
      transitions :from => [:matured], :to => :repaid, guard: [:collateralized?]
      transitions :from => [:repayment_sent], :to => :repaid
      after do
        TradeEmail.new(self).deliver_repayment_notifications if collateralized?
      end
    end

    event :fail_repayment, guards: [:manual_market?] do
      transitions :from => [:matured, :repayment_sent], :to => :repayment_failed
      after do
        # puts "Oh hai"
      end
    end
  end



This state machine also allowed us to delegate transition checks directly to the state machine which cleaned up a ton of procedural code. Our user-defined abilities (or policies) looked A LOT cleaner:

module Abilities
  module InstitutionalUser
    class Trading < Abilities::Base

      def initialize(user)
        super(user)

        can :fund, Trade do |trade|
          # only borrower can mark trade as funded
          trade.buy_execution.institution.eql?(user.institution) && trade.may_fund?
        end
    end
  end
end



The biggest benefit from this implementation was that we didn't have to duplicate state machine logic on the client side. We simply serialized what transitions were available to the trade and once in javascript the checks became rather simple:

# serializers/trade_serializer.rb

class TradeSerializer < ActiveModel::Serializer
  attributes :id,
     :available_transitions,
     :clearing_type,
     :dollar_amount_of_loan,
     :price,
     :quantity,
     :status

  has_one :instrument
  has_one :buy_execution, serializer: ExecutionSerializer
  has_one :sell_execution, serializer: ExecutionSerializer

  # returns an array of available states -> "funding_sent" =>  ["funded", "funding_failed", "busted"]
  def available_transitions
    object.aasm.states(permitted: true).map(&:name)
  end
end



Given the serialized json, angular checks on the front-end became rather simple:

// trade.js

(function() {
  'use strict';

  // don't include executions here; circular dependency
  angular.module('afx.models').factory('Trade', [function() {
    var Trade = function(obj) {
      _.extend(this, obj);
    };

    Trade.prototype.canTransition = function(state) {
      return _.includes(this.available_transitions, state)
    };

  return Trade;
})

// new Trade(jsonBlob).canTransition("funded")



And lastly, our angular directive to control state transitions became simpler:

// directives/tradeState.js


(function() {
  'use strict';

  angular.module('afx.directives').directive('tradeState', [function() {
    return {
      restrict: 'E',
      replace: true,
      scope: {
        trade: '=',
        execution: '=',
        user: '=',
        status: '='
      },
      template: '<button class="btn btn-danger" tooltip="Mark Trade as {{ status | titleCase }}" ng-click="updateStatus(trade, execution, status)" ng-show>{{ status | titleCase }}</button>',
      controller: 'TradeStateController'
    };
  }]);
}());

// controllers/TradeStateController.js

(function() {
  'use strict';

  angular.module('afx.controllers').controller('TradeStateController', [
    '$scope', '$http', '$modal', 'Trade', 'Execution', 'User',
    function($scope, $http, $modal, Trade, Execution, User) {

      // borrower marks funded
      $scope.canFund = function() {
        if (!$scope.user.isInstitutionalUser()) {return false;}

        return $scope.isBorrower() && $scope.trade.canTransition('funded') && $scope.user.can("fund", "Trade");
      };
    }
  ]);
}());

// directive html: 
// <ul class="list-inline">
//   <li><trade-state class="btn-sm" trade="trade" status="'funded'" user="user" ng-show="canFund()"></li>
// </ul>



In conclusion, the state machine implemented above cleaned up a lot of duplication and frustration on our end. At first, I hated seeing business logic and the coupling of TradeEmail creep into the domain layer, but the benefits far outweighed the costs (so far). We're happy with the outcome, and the client was happy with how fast we were able to build a new market!