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?
status
of state
fields, especially when coupled with state transition actions/callbacks, are usually good indicators 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!