Idea: GenServers with Map-based state

Posted on by in Development, Elixir

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 post I’m going to explore a simple improvement to the GenServer API that would make it a little easier to work with.

GenServers are processes that have state that can be transformed when the GenServer receives a message. This state is represented in a single value that is passed into the handle_call or handle_cast function.

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 GenServer that wraps an integer value, and you can increment it by sending it the 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 use state + 1 to increment, because state is no longer just the integer.

Here is our now-refactored GenServer:

To recap, we had to add a defstruct, a type for the module, and we had to modify all four functions in this module to make this work!

What I’d like to see

I’d like to take some inspiration for how ExUnit handles test contexts and propose a new way of managing GenServer state.

State as map

First off, instead of GenServer state being an any, let’s make it a map instead. Furthermore, when you return from handle_call or handle_cast, you will be expected to return a map that will then be merged with the existing state map. If there are no changes, to make, you could either return an empty map with your tuple, or just exclude the new state from your tuple. Excluding the state from the return tuple to indicate not updating it isn’t strictly needed for this to work, but it addresses a mild annoyance I’ve always had with GenServers and since we’re just talking hypothetically here let’s do it!

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.

Separating config data from state data

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).

Putting it all together

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 GenServer API, and I’d like to see some simple improvements like this in the near future and have these improvements pave the way for more dramatic simplifications of these APIs.

In a future blog post, we will implement our own GenServer OTP behavior that follows these semantics.