Programming business processes in Golang

Programming business logic is the programmers daily work. Business logic creates business value for the customer by transforming and manipulating data while executing side effects.

But just implementing business logic isn’t enough. One has to program different parts of a whole business process by glueing the different business logic parts together. And that’s the point where the problems start…

The best way to show what I actually mean is to do it with a simplified example. Let’s say there is an order in an online shop. First the order state is in draft. If it’s in draft we can add order items which contain a price and an amount. After the order has been filled we complete the order so the customer can pay it. After the payment, the order is paid. At any time the order can be cancelled and should be refunded if the customer has paid.

Here is a BPMN workflow for the described process:

Simplified BPMN workflow of the order process

Pretty simple example huh? Let’s face some code:

package main

import (
  "github.com/pkg/errors"
  "github.com/shopspring/decimal"
  "log"
)

// Define some constants for the state of the order
const (
  _Draft_ = "draft"
  _Completed_ = "completed"
  _Paid_ = "paid"
  _Cancelled_ = "cancelled"
)

type (
  // Orderline defines a position in the order
  // it is a simplified struct which is more complex in reality
  OrderLine struct {
    amount decimal.Decimal
    price  decimal.Decimal
  } // OrderLines defines a slice of OrderLine objects
  OrderLines []OrderLine // Order is an order of a customer
  Order struct {
    orderLines OrderLines
    state      string
    paid       bool
    refunded   bool
   }
)

func NewOrder() Order {
  return Order{
     state: _Draft_,
  }
}

// Sum adds up all orderlines and its price * amount
func (ol OrderLines) Sum() decimal.Decimal {
  sum := decimal.NewFromFloat(0)
  for _, orderLine := range ol {
    sum = sum.Add(orderLine.price.Mul(orderLine.amount))
  }
  return sum
}

func (o Order) Println() {
  log.Printf(
    "Order is in state %s. Amount: %s$, Paid: %t, Refunded: %t",
    o.state,
    o.orderLines.Sum().String(),
    o.paid,
    o.refunded,
  )
}

// AddOrderLine adds the  given orderLine to the order
func (o *Order) AddOrderLine(orderLine OrderLine) error {
  if o.state == Draft {
    o.orderLines = append(o.orderLines, orderLine)
    return nil
  } else {
    return errors.New("you are not allowed to edit the order")
  }
}

// Complete the order so no OrderLine can be added
func (o *Order) Complete() bool {
  if o.orderLines.Sum().IntPart() > 0 {
    o.state = _Completed_ return true
  }
  return false
}

func (o *Order) Pay() error {
  // Checks payment and sets the order to paid
  o.paid = true
  o.state = _Paid_ return nil
}

func (o *Order) Cancel() error {
  if o.paid {
    // refund the money to payment type of customer
    o.refunded = true
  }
  o.state = _Cancelled_ return nil
}

func main() {
  var err error
  order := NewOrder()
  order.Println()
  order.AddOrderLine(OrderLine{
    amount: decimal.NewFromFloat(1),
    price:  decimal.NewFromFloat(2),
  })
  order.Println()
  order.AddOrderLine(OrderLine{
     amount: decimal.NewFromFloat(2),
     price:  decimal.NewFromFloat(3),
  })
  order.Println()
  order.Complete()
  order.Println()
  err = order.Pay()
  if err != nil {
    log.Fatal(err)
  }
  order.Println()

  err = order.Cancel()
  if err != nil {
    log.Fatal(err)
  }
  order.Println()
}

This will print:

Order is in state draft. Amount: 0$, Paid: false, Refunded: false
Order is in state draft. Amount: 2$, Paid: false, Refunded: false
Order is in state draft. Amount: 8$, Paid: false, Refunded: false
Order is in state completed. Amount: 8$, Paid: false, Refunded: false
Order is in state paid. Amount: 8$, Paid: true, Refunded: false
Order is in state cancelled. Amount: 8$, Paid: true, Refunded: true

Alright, our example is done and works. Now we have a stable model which got different business logic behaviours: [AddOrderLine, Complete, Pay, Cancel] which also reflect the business process.

The problem

If we program the model like in the example, we will face three problems in the future.

1. Problem: Bus factor

The business process is only understandable by reading the code or knowing it by heart. This results in a really huge bus factor. Even if you have the BPMN diagram which is shown above, it’s getting unusable if you change the code. And yes, sometimes you will have no time to update the diagram.

2. Problem: Complexity and Misbehaviour

The code can result in misbehaviour if you allow admins to control the flow of an order. On the one hand, you could place buttons for each method of the business process. Then the admin could set the order from cancelled to completed. On the other hand, you would include if and switch conditions to display only the buttons which are allowed to be clicked, which will end up in a total mess if the process gets bigger. Also you have to know each state and method and what is allowed and what not. Especially for project beginners it becomes more difficult to get into the project the more complex the business process becomes.

3. Problem: Do one thing and do it well

You handle state logic inside of business logic. Yes this is a problem. The thing is that the methods are not only dealing with business logic they are also handling state transitions and state logic (if this state is set, then do this). This should be avoided and abstracted, refactored in another place. In example if you add or change the business process you will edit the business behaviours and not the process itself.

The solution

“State” belongs to a fundamental model of computer science. It was introduced by Warren McCulloch and Walter Pitts and is known as a “deterministic finite state machine”.

Deterministic finite state machine

A deterministic finite state machine (DFSM) transforms a model from one state into another by applying predefined rules. A DFSM consists of following rules:

  • a finite set of states
  • a finite set of input symbols called the alphabet
  • a transition function
  • an initial or start state
  • a set of accept states

So why I am telling you about finite state machines if we originally wanted to implement a business process?

Well, state based business processes are state machines.

Stateful

So how exactly you can implement the example business process in a state machine without implementing the state machine per project over and over again?

I asked myself the same question and thereby I implemented stateful. Stateful is a state machine library in Golang. It helps you to translate your model from one into another state by predefined rules.

So how to start? If you want to make your model “stateful” it has to implement the stateful interface which only contains the functions State and SetState. So let’s turn back to our code and extend the order by implementing the stateful interface and using stateful.DefaultState as our predefined states.

import (
   "github.com/bykof/stateful"
   "github.com/pkg/errors"
   "github.com/shopspring/decimal"
)

var (
   Draft     = stateful.DefaultState("Draft")
   Completed = stateful.DefaultState("Completed")
   Paid      = stateful.DefaultState("Paid")
   Cancelled = stateful.DefaultState("Cancelled")
)

type (
   OrderLine struct {
      amount decimal.Decimal
      price  decimal.Decimal
   }

   OrderLines []OrderLine

   Order struct {
      orderLines OrderLines
      // set the state to stateful.State
      state      stateful.State
      paid       bool
      refunded   bool
   }

  // We'll get to that in a moment
  AddOrderLineArgs struct {
    NewOrderLine OrderLine
  }
)

// Sum adds up all orderlines and its price * amount
func (ol OrderLines) Sum() decimal.Decimal {
   sum := decimal.NewFromFloat(0)
   for _, orderLine := range ol {
      sum.Add(orderLine.price.Mul(orderLine.amount))
   }
   return sum
}

func NewOrder() *Order {
   return &Order{state: Draft}
}

// State retrieves the current state of the order
func (o Order) State() stateful.State {
   return o.state
}

// SetState sets the state of the current order
func (o *Order) SetState(state stateful.State) error {
   o.state = state
   return nil
}

Now we have to adapt our business logic and remove the state logic out of it:

// AddOrderLine adds the  given orderLine to the order
func (o *Order) AddOrderLine(args stateful.TransitionArguments) (
   stateful.State,
   error,
) {
   addOrderLineArgs, ok := args.(AddOrderLineArgs)
   if !ok {
      return nil, errors.New("args could not be parsed properly")
   }
   o.orderLines = append(
      o.orderLines,
      addOrderLineArgs.NewOrderLine,
   )
   return o.state, nil
}

func (o Order) Complete(_ stateful.TransitionArguments) (
   stateful.State,
   error,
) {
   if o.hasAtLeastOneOrderLine() {
      return Completed, nil
   } else {
      return nil, errors.New("you cannot complete an empty order")
   }
}

func (o *Order) Pay(_ stateful.TransitionArguments) (
   stateful.State,
   error,
) {
   o.paid = true
   return Paid, nil
}

func (o *Order) Cancel(_ stateful.TransitionArguments) (
   stateful.State,
   error,
) {
   err := o.refund()
   if err != nil {
      return nil, err
   }
   return Cancelled, nil
}

func (o *Order) refund() error {
   o.refunded = true
   return nil
}

func (o Order) hasAtLeastOneOrderLine() bool {
   return len(o.orderLines) > 0
}

As you can see, we removed the setting of the state and return now the new state depending on the behaviour of the business logic or an error if the business logic fails. Also every method now receives stateful.TransitionArguments which then can be cast to a specific struct like AddOrderLineArgs.

Now you define your rules, regarding which transition is allowed to proceed from which source state to which destination state:

func main() {
  order := NewOrder()
  stateMachine := stateful.StateMachine{StatefulObject: order}
  stateMachine.AddTransition(
    order.AddOrderLine,
    stateful.States{Draft},
    stateful.States{Draft},
  )
  stateMachine.AddTransition(
    order.Complete,
    stateful.States{Draft},
    stateful.States{Completed},
  )
  stateMachine.AddTransition(
    order.Pay,
    stateful.States{Completed},
    stateful.States{Paid},
  )
  stateMachine.AddTransition(
    order.Cancel,
    stateful.States{Draft, Completed, Paid},
    stateful.States{Cancelled},
  )
}

After we initiated the state we can run our state machine:

func main() {
  // ...
  err = stateMachine.Run(
    order.AddOrderLine,
    AddOrderLineArgs{
       NewOrderLine: OrderLine{
        amount: decimal.NewFromFloat(1),
        price:  decimal.NewFromFloat(2),
       },
    },
  )
  if err != nil {
    log.Fatal(err)
  }
  order.Println()

  err = stateMachine.Run(
    order.AddOrderLine,
    AddOrderLineArgs{
      NewOrderLine: OrderLine{
        amount: decimal.NewFromFloat(2),
        price:  decimal.NewFromFloat(3),
      },
    },
  )
  if err != nil {
    log.Fatal(err)
  }
  order.Println()

  err = stateMachine.Run(
    order.Complete,
    nil,
  )
  if err != nil {
    log.Fatal(err)
  }
  order.Println()

  err = stateMachine.Run(
    order.Pay,
    nil,
  )
  if err != nil {
    log.Fatal(err)
  }
  order.Println()

  err = stateMachine.Run(
    order.Cancel,
    nil,
  )
  if err != nil {
    log.Fatal(err)
  }
  order.Println()
}

This will print out our correct behaviour like before:

Order is in state Draft. Amount: 2$, Paid: false, Refunded: false
Order is in state Draft. Amount: 8$, Paid: false, Refunded: false
Order is in state Completed. Amount: 8$, Paid: false, Refunded: false
Order is in state Paid. Amount: 8$, Paid: true, Refunded: false
Order is in state Cancelled. Amount: 8$, Paid: true, Refunded: true

The state machine will check if the order is in the correct state and proceed with the transition. If the transition fails it will return the error. If the transition returns an unknown state the state machine will return an error too.

So if you try to run the transition “Pay” from state “Draft” it will throw an error:

// Order is in state "Draft"
err = stateMachine.Run(
   order.Pay,
   nil,
)
if err != nil {
   log.Fatal(err)
}
order.Println()

The previous code will print following error to console:

2019/07/30 16:28:17 you cannot run Pay from state Draft
exit status 1

Also there is the possibility to print the actual business process. Just run:

func main() {
  // ...
  stateMachineGraph := statefulGraph.StateMachineGraph{
    StateMachine: stateMachine,
  }
  _ = stateMachineGraph.DrawGraph()
}

This will print following graphviz dot graph to the console:

digraph  {
  Draft->Draft[ label="AddOrderLine" ];
  Draft->Completed[ label="Complete" ];
  Completed->Paid[ label="Pay" ];
  Draft->Cancelled[ label="Cancel" ];
  Completed->Cancelled[ label="Cancel" ];
  Paid->Cancelled[ label="Cancel" ];
  Cancelled;
  Completed;
  Draft;
  Paid;
}

Which you can paste here: https://dreampuf.github.io/GraphvizOnline/ and it will look like this:

Generated state machine graph via graphviz

Conclusion

Let’s compare the three initial problems with the solution:

  1. Now we can reproduce a graph out of the single point of truth: the code. This can be generated and rendered after every commit easily and is even understandable if one doesn’t know BPMN notation.
  2. There is no misbehaviour anymore. The state machine checks the model for proper state and transitions, so we couldn’t fall into an undefined state or a wrong behaviour of the code. Also we can retrieve the next possible states by calling:
availableTransitions := stateMachine.GetAvailableTransitions()
for _, availableTransition := range availableTransitions {
  log.Println(availableTransition.GetName())
}

This will print out (if the order is in the state “Draft”):

2019/07/29 17:18:32 AddOrderLine
2019/07/29 17:18:32 Complete
2019/07/29 17:18:32 Cancel

Which is AWESOME IMHO.

  1. Your business logic handles only business logic, depending on information provided by your model. It only returns the next state or an error and the whole business process behaviour is handled by the state machine.

So next time, if you plan to implement a business process, give stateful a try.