"Tell, Don't Ask" in Elixir: A Story of Pattern-Matching

“Tell, Don’t Ask” is a well-covered topic within object-oriented programming communities. Its goal? Encourage encapsulation by having the caller tell an object to do something instead of checking on state and acting upon it. Almost at odds with the control couple code smell, our goal is to have the caller issue explicit commands without concerning itself with object state.

“Tell, Don’t Ask” in Elixir

Is Elixir object-oriented? From a paradigm perspective, Elixir is a functional language when looking at aspects like immutability, pattern-matching, and functions with inputs and outputs, focused on the sending of messages to “objects” directly. How does “Tell, Don’t Ask” translate?

Thinking about the goal, let’s do some mental mapping. In OOP, objects are a blueprint with information containing behavior (methods) and data (state). In FP, we have functions organized within modules, with state being captured in various values (e.g. Elixir’s Maps or Structs). We want to avoid having the caller (a function) dictate paths based on information present in our data.

Let’s write out some non-idiomatic Elixir and see what we can improve.

defmodule Game.Lobby do
  def add_player(%{game: game} = lobby, player) do
    new_player = cond do
      is_binary(player) ->
        %Game.Player{name: player, id: Game.Player.generate_id}
      is_map(player) ->
        %Game.Player{} |> Map.merge(player)
      true ->
        %Game.Player{}
    end

    %{lobby |
      game: %{game | players: game.players ++ [new_player]}}
  end
end

defmodule Game.Player do
  defstruct id: 0, name: "New player", active: true

  def generate_id do
    UUID.uuid4()
  end
end

Game.Lobby.add_player/2 doesn’t feel right. There’s a significant amount of feature envy as it cares about the various shapes of player and how to construct a %Game.Player{}. Also, why is Game.Player.generate_id/0 public? It seems all Game.Lobby.add_player/2 should care about is managing its own structure (the final two lines of the function).

Instead of having Game.Lobby.add_player/2 care about constructing players, generating ids, and so on, let’s tell Game.Player to handle that instead:

defmodule Game.Lobby do
  def add_player(%{game: game} = lobby, player) do
    %{lobby |
      game: %{game | players: game.players ++ [Game.Player.new(player)]}}
  end
end

defmodule Game.Player do
  defstruct id: 0, name: "New player", active: true

  def new(name) when is_binary(name), do: new(%{name: name, id: generate_id})
  def new(a)    when is_map(a),       do: %__MODULE__{} |> Map.merge(a)
  def new(_),                         do: %__MODULE__{}

  defp generate_id, do: UUID.uuid4()
end

Here, we move player generation to the Game.Player module, where it can determine how best to generate the struct instead of Game.Lobby.add_player/2.

Write declarative (not imperative) code

By moving player creation logic from Game.Lobby.add_player/2 to Game.Player.new/1, we were able to call a single function to take the appropriate action based on data. It is important to note that the data it’s acting upon specifically is behavior to construct a %Game.Player{}.

This becomes more important when using the pipe operator, which shines as a way to transform data.

“Tell, don’t ask” is a way to encourage developers to write declarative code instead of imperative code. Imperative code asks questions before making decisions; declarative code issues a command and expects it to be done correctly.