State vs Stateful Actor
When I started using Akka, it was a common approach to keep state in actor with business logic inside. Later I discovered the shortcomings of this approach, it is hard to reuse logic, add new functionality, testing requires using Akka and I’m testing side effects. To solve these issues I have tried to use state monad. This blog post describes the process of refactoring.
This example project (link to github) is a simple implementation of a vending machine using Akka. It simulates a vending machine with a program that is communicating with the user via a terminal. The user can initiate standard actions like inserting a coin (+1, +5, …), selecting a product (1, 2, …) or withdrawing money (-).
The vending machine actor has the following features:
- track amount of credit
- track products quantity and expiry date
- sell product
- withdraw money on user selection
- notify owner when vending machine has run out of product, product expiry date is out of range or money box is almost full
This is a pretty long list of features and hopefully they have unit tests.
A vending machine actor is responsible for keeping the state of the vending machine, processing input from the user and sending actions to the User outputs and System reports actors. For example if the user buys a product, the vending machine actor updates the quantity of stock, sends a message to the User outputs to release a product and provide change, it can send a message to System reports if a product sells out.
Vending machine actor contains a few var’s to keep state. Beside of var’s there is a receive block with a lot of logic. Snippet below shows essentials. Worth to notice is mixing logic (lines 14–19) and execution of side effect (lines 21–23 updating internal state, lines 27–31 sending messages).
Let’s take a look at the test. As you can see I’m using Akka Testkit. The code below validates a happy path scenario when a users buys a product from drawer “1”.
This code has a few drawbacks. First, we need to use Akka for testing the business code. It requires to starting the actor system and that slows down testing. Second, we need to set up an internal state of the actor by sending messages (underTest ! Credit(10)) and making sure that the messages were processed (userOutput.expectMsg(CreditInfo(10))). Third, we test side effects. Assertion in this test is validating that VendingMachineActor has sent the message GiveProductAndChange(beer, 7) to UserOutputsActor. Additionally if we want to validate the internal state of the actor we have to handle the test specific message GetState.
Part of the tests also have the problem that their assertion is “no side effects executed”. In this case we use expectNoMessage() from AkkaTestkit. By default this call waits for 3 seconds to make sure that no message was sent. It slows down our build. We can reduce this time but this can make tests flaky.
The business logic implemented inside VendingMachineActor depends on Akka. This actor has a big receive block and a few var’s to keep the state. Adding or changing code requires to reason about whole actor class.
Refactoring to state monad
What is a state monad
A state monad is a function that accepts states and returns pairs of new states and effects:
State -> (State, Effect)
The state monad is a monad so it provides the flatMap method. Thanks to this we can compose state monads:
Using for-comprehension, the new state monad is built (but not run yet). Application of initial state on stateMonad1 will produce effect1 and new state (let’s call it state1). The effect is assigned to effect1. Next, stateMonad2 is called with state1 as argument and the effect is assigned to effect2. In the yield section we can build final result using the results from steps below.
Now we can run newMonad with initialState as argument. As result we get new state and effects.
The details of state monad implementation in Cats you can find in “Scala with cats” book (available here).
Reimplementing new logic alongside old one
I have decided to create a new actor instead of changing the existing one (to be able to compare these two implementations). The new implementation uses actor (SmActor) to keep the state between calls, communication and calling state monad. All business logic I want to implement as state monad. SmActor will have the same API so I can reuse the existing tests. In this way I can check if both implementation are doing the same. Later I will write tests for state monads.
In this approach the actor keeps only one variable — state. This state will consist of all data required to be processed by the state monad. Below is definition of state class:
In SmActor I have just defined one var with state:
I can now reduce cases in receive block:
There is one case for all business events. Actor also contains case GetState for tests compatibility.
Object VendingMachineSm with method compose is missing. Let’s start the implementation. As shown above we can implement logic as a few state monads and combine them in compose method. First I want to define the return type:
Implementation of logic can be divided into smaller monads and later combined. First method to implement will be updating credit when someone inserts or withdraws a coin:
After inserting coin (Credit(value)), credit is increased and message to user about current credit is generated as output. It is worth to mention that no side effect (sending messages to other actors) is executed at this point.
If users decide to withdraw money, the state is updated with zero credit and message to user is created. For all other actions we can return state without modifications and results.
Now we can validate the implementation with test:
As you can see, logic for updating credit is separated.
The rest of logic can be implemented with same fashion (check logic and tests). We can use all small state monads to create whole process. To do this we are using for-comprehension. State is modified and passed to the next state monad. The results are collected and combined in yield part. Still, there was no execution of state monad. We have just built bigger one.
Testing in FP way
With this implementation we can test logic of “small state monads” and composed one. These tests do not execute side effects. All effects returned are returned by state monad. In this way assert method has all data, we don’t have to worry about checking mocks or TestActorRef for side effects. In actor tests we were testing that no side effects were executed for specific amount of time, with FP we have to just check the result returned. Additionally we don’t have to start actor systems for testing. Combining this, test are much quicker.
Also we can easily run multiple actions and check intermediate results:
In actor test, receive method is under test. receive is just alias for Any => Unit. It means that unknown side effect can be performed. Using pure FP whole result is returned by tested method. In our case it is new state and effects to execute (State=>(State, Effect)). In such case it is easy to validate if returned value matches expectations.
Separation of logic and state bring benefits for testing. We don’t have to set up internal state (of object under test) by calling its methods (or sending messages). In our case if we want to test Withdraw scenario we have to insert coin into vending machine. In more complex scenario it may require more steps to prepare internal state. When object with logic is stateless, we can just pass state as one of arguments.
As I have shown above, using FP, we can compose our program with smaller parts. Focused only on specific functionality state monads can be chained together to build complex logic.
Dividing logic into smaller parts allows us to test simpler functions. We can also test complex logic, builded from smaller parts. Business logic do not depends on frameworks like Akka and threads. It can be tested easily.
Smaller parts of logic can be reused in different parts of program. Complex logic can be build using these smaller parts. If our functions are pure (no side effects and mutating state), we are sure that combining these functions will not bring unexpected behaviour.
As I mentioned above, our business logic should not have any side effects. All effects to execute are returned by method and we can validate output without risk of missing important detail.
Also, our tests are much quicker because we don’t have to start Actor System or making sure that no side effect was executed.
We have moved business logic from actor to separate object. The actor is responsible only for keeping states between calls and thread synchronization. Thanks to state monad, the business logic was divided into smaller parts. These smaller parts are used to build complex logic. Tests are lighting fast.