Published on

An Opinionated Recipe for Building GenServers in Elixir

Authors

I've been working nearly exclusively in Elixir for the past nine years, and in that time I've seen all sorts of codebases in a variety of industries. They've all had their own conventions and patterns and have very rarely shared many similarities, but one thing nearly all of them have had in common is their lackluster GenServer implementations.

GenServers are a great fit for building stateful, long-lived processes that can be restarted and recovered from crashes, but they also have a reputation of being a bit of a pain to work with. In my opinion and experience, this pain is entirely synthetic and can be mitigated by following a few simple guidelines.

Firstly, I feel it's important to mentally separate the ideas of GenServer machinery from that of your implementation, especially when it comes to testing. The idea of GenServer machinery itself is not difficult to reason about, nor should you have to test it. Conversely, your GenServer implementation must be tested, and it's important to test it in a way that doesn't rely on the GenServer machinery.

As an example, let's imagine a GenServer that simply returns a mutated string passed to it via GenServer.call/2. It takes the string, converts it to uppercase, and replaces all instances of "i" with "y". Stepping into a new codebase as an engineer, you can reasonably expect to find something that's structurally similar to the following code.

defmodule MyApp.MyGenServer do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def init(_) do
    {:ok, nil}
  end

  def handle_call({:mutate, string}, _from, state) do
    new_string =
      string
      |> String.upcase()
      |> String.replace("i", "y")

    {:reply, new_string, state}
  end
end

This is an extremely contrived example, but it's a good one to illustrate the point. The handle_call/3 function is the only thing that's different from the default GenServer implementation, and it's the only thing that's worth testing. The rest of the code is just boilerplate, and for our purposes, this is perfectly fine.

Now, let's imagine that we're going to write a test for this GenServer. We'll start by writing a test that verifies that the GenServer is started and responds to a call. This is a good test, and it's a great starting point.

defmodule MyApp.MyGenServerTest do
  use ExUnit.Case

  test "start_link/1 returns a GenServer" do
    assert {:ok, pid} = MyApp.MyGenServer.start_link([])
    assert is_pid(pid)
  end

  test "handle_call/3 returns a string" do
    {:ok, pid} = MyApp.MyGenServer.start_link([])
    assert {:ok, "HELLO"} == GenServer.call(pid, {:mutate, "hello"})
  end
end

This will test the functionality, but it's not a great pattern and doesn't scale well. There's no need to test that GenServer callbacks work correctly, as that's already tested in the BEAM itself. The only thing we need to care about testing is the logic we've written in the handle_call/3 function. This starts by extracting the logic into an entirely separate module, and then we can test it in isolation.

The new module might look something like this:

defmodule MyApp.MyGenServer.Logic do
  def mutate(string) do
    string
    |> String.upcase()
    |> String.replace("i", "y")
  end
end

And its corresponding tests might look like this:

defmodule MyApp.MyGenServer.LogicTest do
  use ExUnit.Case

  test "mutate/1 returns an upcased string" do
    assert "HELLO" == MyApp.MyGenServer.Logic.mutate("hello")
  end

  test "mutate/1 replaces i with y" do
    assert "KYTTY" == MyApp.MyGenServer.Logic.mutate("kitty")
  end
end

This is exceptionally easy to reason about. The above tests a single function call which exists outside of your GenServer implementation. There's no need to start the server, track its pid, use helper functions to send the correct messages, or otherwise manage the GenServer lifecycle. This pattern embraces the idea that the logic your GenServer executes within its callbacks has virtually nothing to do with the GenServer itself, and is simply responsible for creating a new state.

This is a contrived example, but I'm sure you can imagine how much simpler life is when you work with complex GenServer functionality in the same way you would with any old module otherwise. Reasoning about GenServer lifecycle events in a large test suite is extremely difficult and makes it difficult to write meaningful tests and wrap your mind around testing edge cases. It also frees you up from having to manage GenServer lifecycle code within tests that might over time grow to be very large and complex.

In my own ideal world, and I did say this was an opinionated recipe, the ideal GenServer module file contains absolutely nothing but the GenServer boilerplate and single-line calls into external modules to perform all of your actual logic. This way, the GenServer module itself is extremely slim, and you can abstract your own logic in any way you choose such that it's easy to reason about and test.

So let's take a look at the ideal GenServer module file:

defmodule MyApp.MyGenServer do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def init(_) do
    {:ok, nil}
  end

  def handle_call({:mutate, string}, _from, state) do
    # If you can help it, only one single function call for every handler in the GenServer
    # that creates a new state. Ever. Pass all of the handle_call function arguments into
    # Logic.mutate if you need to, but only ever limit the exposure of your implementation to the
    # GenServer module with a single public function call
    new_string = MyApp.MyGenServer.Logic.mutate(string)
    {:reply, new_string, state}
  end
end

Again, this is a contrived example, but I'm sure you can see how this pattern would dramatically shorten and simplify things when working with big and complex GenServers. In my opinion, even if the logic needed for a given GenServer handler is thousands of lines, there's no reason you should ever need to expose more than a single public entry point function call into your GenServer module.

Thanks for reading, and I hope this helps you as much as it's helped me.