Anemic GenServer anti-pattern?

Hey all,

I’m wondering how to articulate what I consider a design smell, and I’d appreciate hearing how you think about this topic to clarify and crystalize my thinking. Including if this isn’t a smell at all to you!, perhaps I got it all backwards :slight_smile:

I brushed up against a genserver pattern with functions that delegate to another module’s functions, and doesn’t really do anything stateful beyond capturing a piece of data. Like this:

defmodule MyApp.FooClient do
  use GenServer

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

  def get_file(pid, file_path) do
    GenServer.call(pid, {:get_file, "specific_dir/" <> file_path})
  end

  @impl GenServer
  def init(opts) do
    conf = Application.get_env...
    {:ok, conn} = MyApp.OtherClient.connect(conf)
    {:ok, conn}
  end

  @impl GenServer
  def handle_call({:get_file, file_path}, _from, conn) do
    result = MyApp.OtherClient.read_file(conn, file_path)
    {:reply, result, state}
  end
end

Here, MyApp.FooClient genserver captures a conn object on init which it passes into MyApp.OtherClient functions, and there is some light filepath concatenating in the client function. But that’s all the genserver does. As I see it, it causes callers to work like this:

{:ok, pid} = MyApp.FooClient.start_link()
MyApp.FooClient.read(pid, "foo")

But IMO callers could just as well just call:

{:ok, conn} = Application.get_env... |> MyApp.FooClient.connect() 
MyApp.FooClient.read(conn, "foo")

The latter keeps the Foo submodules all functional, and the former I think of as an “anemic GenServer” in reference to the timeless topics of anemic objects and anemic domains.

But maybe this is already an established term? I’m new to Elixir so I might’ve missed the official memo. And I would just love to hear how you navigate these tradeoffs, and how you articulate when and when not to reach for a GenServer, to align my thinking and terminology.

Honestly the example you showed makes no sense, maybe there are some missing details?

What is the point of keeping the conn as state in a genserver? Maybe it’s used as some kind of caching/tracking mechanism?

1 Like

Ok that’s good feedback thanks, I’ve added a fairly complete example (with all the business-specific details left out): The client-function is usefully concatenating specific_dir (I appreciate it doesn’t look like much in this example, but for this thread that is value-adding), but my codeysense triggers when I see it just delegates the server impl. to a MyApp.OtherClient function.

This does not belong in init, it should be in handle_continue.

And I would not cache the entirety of the Plug.Conn struct, I’d just extract out the parts I need for the path concatenation.

1 Like

Great info with handle_continue, I’ll look into that thank you. Also agreeing on not storing a big struct in state.

But you would keep it as genserver, and not unroll it? I’d love to understand how you see that better, I think it’ll expand my design sense. Is it relevant that the genserver doesn’t otherwise alter its state, or is that not even part of your genserver dos/donts?

You can keep state in any random data structure you formulate and you can just pass it around. The need for a GenServer arises when that state can be modified in parallel by several different runtime entities – a GenServer helps you serialize requests for access to its states so there are no nasty parallel data synchronization conflicts.

That, plus a GenServer is needed when you need to model such a runtime entity that is “live” (like a connection to a 3rd party API with strong rate limit rules). Those are the two main considerations on whether you should roll a GenServer.

To answer your question slightly more directly and without being only a wooden philosopher: you absolutely don’t need a GenServer just so you can calculate a filesystem path; just store the relevant input parts of your algorithm in whatever (likely a map / struct) and you can then just have a function f.ex. get_our_custom_path and pass this map/struct to it. The function will then find the parts it needs inside the structure and give you the appropriate result.

There’s no need for a GenServer in your case unless I am reading you very wrong – or you have mis-specified your use-case.

Other people have articulated this better than me. Have you read The Erlangelist - To spawn, or not to spawn??

4 Likes

I haven’t read that link no, but I will! Thanks.

Initial nitpick: as written, this is a trap to bite the unwary in production - the second process that tries to start a FooClient will get an {:error, {:already_started, pid}} and crash.


IMO there’s a mix of a couple different patterns here, with various vibes:

  • the “holder” pattern, where a long-lived process holds an expensive piece of state (here the result of MyApp.OtherClient.connect). Having exactly one is a little unusual, but it’s straightforward to build a :poolboy pool where the workers look pretty much like this. There are performance consequences to a design like this, though - for instance, if result in :get_file is a large data structure. See also discussion in the DbConnection docs about the difference between doing the work in the holder vs doing the work in the caller.

  • The “exactly one process” pattern, exemplified by passing a hardcoded name: to start_link. Usually you’d see the client API simplified to omit pid and instead use GenServer.call(__MODULE__, ...), and add MyApp.FooClient to the supervision tree.

    This can be valuable if you’re modeling something there’s actually only one of per-node, or that you need exactly-one-at-a-time behavior from.

  • finally, if OtherClient.connect is cheap and there’s no one-at-a-time requirement, it’s trending closer to the “code organization by process” anti-pattern

6 Likes

I think there is another possibility that has not been mentioned yet, and that is isolation of failures. When interacting with something outside of the system, wrapping the interactions in a process will isolate unexpected failures to just that process, which can be very valuable.

4 Likes

I’m also not sure if I’d consider this a smell. I had to write GenServers like this to intentionally bottleneck the amount of interactions with an external system to either respect rate-limits, not open too many connections or to reduce the strain on these systems (Elixir’s concurrency can easily DDOS some servers :upside_down_face:).

1 Like