I recently gave a talk at Empex LA in which I talked about my desire to see simplifications and enhancements to using some of the OTP behaviors offered in Elixir. In this
GenServers are processes that have handle_call
handle_cast
This is easy to manage if your GenServer only needs to manage a single piece of information. But as soon as you find that your GenServer needs multiple pieces of information in state, you need to substantially refactor it.
Suppose we have a increment
message:
State is simple enough to work with here.
But let’s suppose we wanted to be able to increment by values other than just 1?
First we’ll make a struct for the module:
We’ll also need a type:
Now, when we start the GenServer, we want to specify that increment_by
value. For backwards compatibility, we will default to an increment_by
value of 1:
And now the init
function needs updating too:
And when we want to check the current value of the integer, we can no longer just return state; we need to specifically grab the value
part of our struct:
And, of course, when we increment, we can no longer just state + 1
state
Here is our now-refactored GenServer:
To recap, we had to add defstruct
I’d like to take some inspiration for how ExUnit handles test contexts and propose a new way of managing GenServer state.
First off, instead of GenServer state being any
map
handle_call
handle_cast
state
Now your individual functions no longer need to be concerned with the overall structure of state
; you can instead pattern match on just the pieces of state that your function needs, returning a map of the values that you want to have merged into state
. This gives your GenServer state the freedom to store more than a single value without burdening all of your handle_call
and handle_cast
callbacks with the complexity of that.
In case you have a use case where it would be better to replace the entire state with a brand new state(which is how GenServers currently work), this could be accomplished with a different reply atom, like :reply_replace_state
, or :noreply_replace_state
.
It may also be worth considering adding helper functions that can construct these return tuples, saving the developer from needing to remember a specific tuple structure.
Let’s take this a step further. In our example GenServer, we have 2 types of data in our state: static configuration data that affects the GenServer’s behavior, and state data.
If our config is not stateful, let’s not keep it in state at all! To do this, we have handle_call
and handle_cast
take a parameter for state
, and a separate parameter for config
.
Now, we can treat our config
as an immutable property of the GenServer because in your message handler callbacks you aren’t expected to update your config. As an added bonus, this provides you with some safety in knowing that you config won’t accidentally get overwritten by a bad merge of data into state
.
The config can be static in this case, but that doesn’t mean we can never update it! This GenServer can provide a built-in update_config
callback. After sending an update_config
message to the GenServer, subsequent messages would be processed with the new config.
You could have the option to implement your own version of this in case you needed to have special handling(for instance, maybe you want to write the change to a log or a monitoring service).
Let’s take a look at how our theoretical GenServer might now look with these characteristics:
By having the state data always be a map, it’s never a big leap to add additional values. And if we want to change the amount we increment by, it’s nice and easy:
This would be a simple quality of life improvement to using the
In a future blog post, we will implement our own GenServer OTP behavior that follows these semantics.