Fl4m3Ph03n1x

Fl4m3Ph03n1x

Background

As the title implies, I am reading Funcional Web Development with Elixr, OTP and Phoenix and I have finished today chapter 4 where they introduce OTP and GenServers.

For those unfamiliar, the book walks you through the process of making a Battleships game (but with a different name).

Up until this chapter, everything was nicely separated - we had our business entities (Ships, Guesses, a Board, etc) and our Business Logic in the form of a state machine (which I think is rather brilliant).

So now we have the entities to play the game and the logic. This is where the Game entity comes along and this is what confused me.

Questions

The Game entity is a GenServer. Up until now, Ships and Guesses were merely modules with functions. But now we introduce an OTP behavior to the business entities. This brings up a few questions:

  1. Isn’t functional programming supposed to decouple logic and entities from third party concepts like OTP behaviours? I mean, if I decide to port this app tomorrow, I can’t use the functional core because it is coupled to OTP.
  2. Should OTP behaviours be part of the entity layer?
  3. Should I even care if an entity is an OTP behaviour or not?

Looking forward to someone with some architectural knowledge for some hints on how to see this.

Showing Posts 1 to 10

LostKobrakai

LostKobrakai

You could always go as far and do it like Dave Thomas proposes and separate the GenServer code from the pure state manipulation.
But keep in mind: The game state is still a struct and therefore plain data. Also the state transitions are pure (state + input => new_state). There’s just some OTP boilerplate around it, which you’re correct, can’t be simply ported to a different language. But I’m wondering if that’s really a useful metric to judge code by. It seems like wanting to not use e.g. closures, because they might not be available in other languages.

Fl4m3Ph03n1x

Fl4m3Ph03n1x OP

I am actually a big fan of Dave Thomas and his talks and approaches actually. His course is on my To Buy list :smiley:

Wish I could talk to him more though. He looks so provocative in his talks but I don’t see him often here. Maybe I need to be more active? Who knows :smiley:

Good argument. My counter argument is that closures are a core feature of any functional language and a core feature of most languages these days. OTP is not a core feature of anything besides Erlang (and some of the languages built on top of the BEAM VM depending on the level of inter-operability).

I am willing to push it even further and say that the idea of functional programming is to decouple code from side effects and third party tools and that because OTP is an external element code should be decoupled from it.

As anecdotal as my life may be to be used as evidence I am also going to say that I suffered through some really harsh migrations in the past because logic was coupled with language features. You know the layered architecture schema? (onion, hexagonal, lasagna, you name it) - it is impossible to do when everything is coupled in a petri dish.

I am not saying “don’t use language specific features”. I am saying (and I think this is the purpose of FP in general) “decouple your logic from external systems”.

Going back to the topic, do you agree with the author’s decision?
Would you find this code easy to port to erlang or another language?

jeremyjh

jeremyjh

Behaviours are a language feature that are orthogonal to concurrency, and which you may take advantage without using any OTP behaviour. GenServer is one particular behaviour, but anywhere you need to decouple interface from implementation a Behaviour can be useful, even in a single-process. A common use case is for testing, we follow the pattern outlined in Jose’s blog article at some of our application library boundaries.

eta: This is a very common pattern in other languages with contract/implementation systems such as C#, Java, C++, Typescript, and even functional languages like OCaml and Haskell.

A GenServer is appropriate when you need single-threaded access to shared state between multiple processes. This could very well be a good way to model a lot of games, but it wouldn’t be essential and there are other means of sharing mutable data in Elixir.

LostKobrakai

LostKobrakai

I very much do, because the book is trying to explain to the reader how GenServers work and not how layered architecture works. Also while OTP is indeed a feature of the BEAM in this case it’s just the language specific implementation of “long running process holding state”. You probably could even use an Agent, which will reduce the lines of “stateful boilerplate” even more. If you’d like to port the application you’ll need to find another way to keep the state around, but the fundamental transformations of the actual state will stay the same.

Granted I know Elixir properly, yes. The handle_… callbacks are probably quite easy to translate to e.g. a function(state, input) :: {new_state, effects} format, like e.g. elm is using it. Then you just need to find a way to persist the state.

Fl4m3Ph03n1x

Fl4m3Ph03n1x OP

@jeremyjh I am fairly familiar with behvaiours in general. It’s the OTP part hat got me confused (they are special after all, or so I thought). Although you make a good point (if I understand you correctly): “OTP behaviours are no different from any other behaviour.” The logical implication here is that if I port this game core to something else, I just need an implementation that obeys the contract specified by the OTP behaviours and all will be fine.

I got a different idea when I got an exemplar of the book (page xii):

Throughout this book, we’ll be building a game in distinct layers (… )

The author then proceeds to describe how each chapter will focus on a specific layer until we reach the outermost layer, which is where we add Phoenix.

Yes I could, but we both know Agents are glorified GenServers :stuck_out_tongue:
Besides this is not where I want to focus the discussion.

So, you see no downsides whatsoever of coupling logic to specific OTP behaviours?
This brings up another interesting question “What are the disadvantages of coupling logic to OTP behaviours, if any?” (for another post maybe)


So, I take it from both of you that the answer to:

  1. Should I even care if an entity is an OTP behaviour or not?
    Is a “No, it’s not important because it’s all a matter of implementing the contracts (behaviours) you use”.

Do I get it right?

peerreynders

peerreynders

Other languages don’t have the concurrency primitives (spawn, send, receive), process linking and monitoring that OTP is based on.

At the core a process is an infinitely recursing function where the updated state is supplied on each recursive call - which is equivalent to an infinite iteration.

One uses Erlang/Elixir to take advantage of these features and to be able to structure behaviour in a way that isn’t possible in other environments.

Would you find this code easy to port another language?

Fundamentally you should chose Elixir/Erlang because it makes it easier to express the solution to your problem - it follows that it would be more difficult to do in another language.

In his book Seven Languages in Seven Weeks , Bruce Tate suggests that “Erlang makes hard things easy and easy things hard.”

Maybe the problem is that you are trying to classify “Game” as an entity rather than as the “engine” of the application - this reminds me of the thought concerns vs. runtime concerns discussion with regards to To spawn, or not to spawn? which had an influence in the rewrite of the book.

Coupling :003:

Essentially the Game “entity” you are looking for is either the GenServer state or some significant part of it.

So indeed to allay your concerns you could factor that part out - away from the GenServer if you wish.


I presume we are talking about this:
https://media.pragprog.com/titles/lhelph/code/gen_server/lib/islands_engine/game.ex

lance

lance

Author of Functional Web Development with Elixir, OTP, and Phoenix

Hi @Fl4m3Ph03n1x,

I’m glad you liked the state machine chapter! I know you had some questions before you read it, and it seems like the chapter addressed those concerns.

It seems that the ultimate question you’re asking is, “Is it ok to represent a domain entity as a GenServer?” (Please let me know if I’m mischaracterizing that.)

My answer would be a definite “yes”.

To my eye, bringing OTP and Behaviours into the conversation is having the effect of muddying the waters a bit.

When we build a GenServer, what are we really doing? We’re defining callback functions that will work in a separate process. That’s it. OTP provides for the common wiring and plumbing to make that happen.

We’re still working with modules, functions, and data. The difference is that they are designed to run in a separate process (or processes).

lance

lance

Author of Functional Web Development with Elixir, OTP, and Phoenix

I think your first paragraph here really speaks to the original question. Thank you!

peerreynders

peerreynders

Better?

# alias IslandsEngine.{Game, Rules}
# {:ok, game} = Game.start_link("Miles")
# Game.guess_coordinate(game, :player1, 1, 1)
# Game.add_player(game, "Trane")
# Game.position_island(game, :player1, :dot, 1, 1)
# Game.position_island(game, :player2, :square, 1, 1)
# state_data = :sys.get_state(game)
# state_data = :sys.replace_state(game, fn data -> %{state_data | rules: %Rules{state: :player1_turn}} end)
# state_data.rules.state
# Game.guess_coordinate(game, :player1, 5, 5)
# Game.guess_coordinate(game, :player1, 3, 1)
# Game.guess_coordinate(game, :player2, 1, 1)

defmodule IslandsEngine.State do
  alias IslandsEngine.{Board, Coordinate, Guesses, Island, Rules}

  @players [:player1, :player2]

  def init(name) do
    player1 = %{name: name, board: Board.new(), guesses: Guesses.new()}
    player2 = %{name: nil, board: Board.new(), guesses: Guesses.new()}
    %{player1: player1, player2: player2, rules: %Rules{}}
  end

  def add_player(state, name) do
    with {:ok, rules} <- Rules.check(state.rules, :add_player) do
      state
      |> update_player2_name(name)
      |> update_rules(rules)
      |> success()
    else
      :error -> :error
    end
  end

  def position_island(state, player, key, row, col) when player in @players do
    board = player_board(state, player)

    with {:ok, rules} <-
           Rules.check(state.rules, {:position_islands, player}),
         {:ok, coordinate} <-
           Coordinate.new(row, col),
         {:ok, island} <-
           Island.new(key, coordinate),
         %{} = board <-
           Board.position_island(board, key, island) do
      state
      |> update_board(player, board)
      |> update_rules(rules)
      |> success()
    else
      error -> error
    end
  end

  def set_islands(state, player) when player in @players do
    board = player_board(state, player)

    with {:ok, rules} <- Rules.check(state.rules, {:set_islands, player}),
         true <- Board.all_islands_positioned?(board) do
      state
      |> update_rules(rules)
      |> success({:ok, board})
    else
      :error -> :error
      false -> {:error, :not_all_islands_positioned}
    end
  end

  def guess_coordinate(state, player, row, col) when player in @players do
    opponent_key = opponent(player)
    opponent_board = player_board(state, opponent_key)

    with {:ok, rules} <-
           Rules.check(state.rules, {:guess_coordinate, player}),
         {:ok, coordinate} <-
           Coordinate.new(row, col),
         {hit_or_miss, forested_island, win_status, opponent_board} <-
           Board.guess(opponent_board, coordinate),
         {:ok, rules} <-
           Rules.check(rules, {:win_check, win_status}) do
      state
      |> update_board(opponent_key, opponent_board)
      |> update_guesses(player, hit_or_miss, coordinate)
      |> update_rules(rules)
      |> success({hit_or_miss, forested_island, win_status})
    else
      error ->
        error
    end
  end

  defp player_board(state, player),
    do: Map.get(state, player).board

  defp opponent(:player1),
    do: :player2

  defp opponent(:player2),
    do: :player1

  defp update_rules(state, rules),
    do: %{state | rules: rules}

  defp update_player2_name(state, name),
    do: put_in(state.player2.name, name)

  defp update_board(state, player, board),
    do: Map.update!(state, player, fn player -> %{player | board: board} end)

  defp update_guesses(state, player, hit_or_miss, coordinate) do
    update_in(state[player].guesses, fn guesses ->
      Guesses.add(guesses, hit_or_miss, coordinate)
    end)
  end

  defp success(state),
    do: {:ok, state}

  defp success(state, other),
    do: {:ok, state, other}
end

defmodule IslandsEngine.Game do
  use GenServer

  alias IslandsEngine.State

  ## --- API
  @players [:player1, :player2]

  def add_player(game, name) when is_binary(name),
    do: GenServer.call(game, {:add_player, name})

  def position_island(game, player, key, row, col) when player in @players,
    do: GenServer.call(game, {:position_island, player, key, row, col})

  def set_islands(game, player) when player in @players,
    do: GenServer.call(game, {:set_islands, player})

  def guess_coordinate(game, player, row, col) when player in @players,
    do: GenServer.call(game, {:guess_coordinate, player, row, col})

  ## ---

  def via_tuple(name), 
    do: {:via, Registry, {Registry.Game, name}}

  def start_link(name) when is_binary(name),
    do: GenServer.start_link(__MODULE__, name, name: via_tuple(name))

  def init(name),
    do: {:ok, State.init(name)}

  def handle_call({:add_player, name}, _from, state) do
    case State.add_player(state, name) do
      {:ok, next_state} ->
        reply_success(next_state, :ok)

      error ->
        {:reply, error, state}
    end
  end

  def handle_call({:position_island, player, key, row, col}, _from, state) do
    case State.position_island(state, player, key, row, col) do
      {:ok, next_state} ->
        reply_success(next_state, :ok)

      error ->
        {:reply, error, state}
    end
  end

  def handle_call({:set_islands, player}, _from, state) do
    case State.set_islands(state, player) do
      {:ok, next_state, reply} ->
        reply_success(next_state, reply)

      error ->
        {:reply, error, state}
    end
  end

  def handle_call({:guess_coordinate, player, row, col}, _from, state) do
    case State.guess_coordinate(state, player, row, col) do
      {:ok, next_state, reply} ->
        reply_success(next_state, reply)

      error ->
        {:reply, error, state}
    end
  end

  defp reply_success(state, reply), do: {:reply, reply, state}
end

Now State contains the logic/core (and can be tested separately) - Game has been reduced to a process (GenServer) shell.

peerreynders

peerreynders

Now while it’s nice to not have the logic conflated with the GenServer ceremony it can get a bit boilerplate-y when you are dealing with numerous GenServers.

One compromise:

  • be more organized inside the GenServer callback module so that it is very clear what is what
  • in the test suite define some helper functions that make it easier to test the callback functions.

That way the “conflation” can be a bit less distracting.

# alias IslandsEngine.{Demo, Rules}
# state = Demo.init("Miles")
# {:reply, :error, state} = Demo.guess_coordinate(state, :player1, 1, 1)
# {:reply, :ok, state} = Demo.add_player(state, "Trane")
# {:reply, :ok, state} = Demo.position_island(state, :player1, :dot, 1, 1)
# {:reply, :ok, state} = Demo.position_island(state, :player2, :square, 1, 1)
# state = %{state | rules: %Rules{state: :player1_turn}}
# {:reply, {:miss, :none, :no_win}, state} = Demo.guess_coordinate(state, :player1, 5, 5)
# {:reply, :error, state} = Demo.guess_coordinate(state, :player1, 3, 1)
# {:reply, {:hit, :dot, :win}, state} = Demo.guess_coordinate(state, :player2, 1, 1)

defmodule IslandsEngine.Demo do
  alias IslandsEngine.Game

  ### helper functions for testing - i.e. should be under "test"" ###

  def init(name) do
    {:ok, state} = Game.init(name)
    state
  end

  def add_player(state, name),
    do: Game.handle_call({:add_player, name}, self(), state)

  def position_island(state, player, key, row, col),
    do: Game.handle_call({:position_island, player, key, row, col}, self(), state)

  def set_islands(state, player),
    do: Game.handle_call({:set_islands, player}, self(), state)

  def guess_coordinate(state, player, row, col),
    do: Game.handle_call({:guess_coordinate, player, row, col}, self(), state)
end

defmodule IslandsEngine.Game do
  use GenServer

  alias IslandsEngine.{Board, Coordinate, Guesses, Island, Rules}

  # --- GenServer Client API ---

  @players [:player1, :player2]

  def add_player(game, name) when is_binary(name),
    do: GenServer.call(game, {:add_player, name})

  def position_island(game, player, key, row, col) when player in @players,
    do: GenServer.call(game, {:position_island, player, key, row, col})

  def set_islands(game, player) when player in @players,
    do: GenServer.call(game, {:set_islands, player})

  def guess_coordinate(game, player, row, col) when player in @players,
    do: GenServer.call(game, {:guess_coordinate, player, row, col})

  # --- GenServer Ceremony ---

  def via_tuple(name),
    do: {:via, Registry, {Registry.Game, name}}

  def start_link(name) when is_binary(name),
    do: GenServer.start_link(__MODULE__, name, name: via_tuple(name))

  def init(name),
    do: {:ok, game_init(name)}

  def handle_call({:add_player, name}, _from, state),
    do: handle_add_player(state, name)

  def handle_call({:position_island, player, key, row, col}, _from, state),
    do: handle_position_island(state, player, key, row, col)

  def handle_call({:set_islands, player}, _from, state),
    do: handle_set_islands(state, player)

  def handle_call({:guess_coordinate, player, row, col}, _from, state),
    do: handle_guess_coordinate(state, player, row, col)

  def handle_info(:first, state) do
    IO.puts("This message has been handled by handle_info/2, matching on :first.")
    {:noreply, state}
  end

  defp reply_success(state_data, reply), do: {:reply, reply, state_data}

  # --- Game Module Logic

  defp game_init(name) do
    player1 = %{name: name, board: Board.new(), guesses: Guesses.new()}
    player2 = %{name: nil, board: Board.new(), guesses: Guesses.new()}
    %{player1: player1, player2: player2, rules: %Rules{}}
  end

  defp handle_add_player(state, name) do
    with {:ok, rules} <- Rules.check(state.rules, :add_player) do
      state
      |> update_player2_name(name)
      |> update_rules(rules)
      |> reply_success(:ok)
    else
      :error -> {:reply, :error, state}
    end
  end

  defp handle_position_island(state, player, key, row, col) do
    board = player_board(state, player)

    with {:ok, rules} <-
           Rules.check(state.rules, {:position_islands, player}),
         {:ok, coordinate} <-
           Coordinate.new(row, col),
         {:ok, island} <-
           Island.new(key, coordinate),
         %{} = board <-
           Board.position_island(board, key, island) do
      state
      |> update_board(player, board)
      |> update_rules(rules)
      |> reply_success(:ok)
    else
      :error ->
        {:reply, :error, state}

      {:error, :invalid_coordinate} ->
        {:reply, {:error, :invalid_coordinate}, state}

      {:error, :invalid_island_type} ->
        {:reply, {:error, :invalid_island_type}, state}
    end
  end

  defp handle_set_islands(state, player) do
    board = player_board(state, player)

    with {:ok, rules} <- Rules.check(state.rules, {:set_islands, player}),
         true <- Board.all_islands_positioned?(board) do
      state
      |> update_rules(rules)
      |> reply_success({:ok, board})
    else
      :error -> {:reply, :error, state}
      false -> {:reply, {:error, :not_all_islands_positioned}, state}
    end
  end

  defp handle_guess_coordinate(state, player, row, col) do
    opponent_key = opponent(player)
    opponent_board = player_board(state, opponent_key)

    with {:ok, rules} <-
           Rules.check(state.rules, {:guess_coordinate, player}),
         {:ok, coordinate} <-
           Coordinate.new(row, col),
         {hit_or_miss, forested_island, win_status, opponent_board} <-
           Board.guess(opponent_board, coordinate),
         {:ok, rules} <-
           Rules.check(rules, {:win_check, win_status}) do
      state
      |> update_board(opponent_key, opponent_board)
      |> update_guesses(player, hit_or_miss, coordinate)
      |> update_rules(rules)
      |> reply_success({hit_or_miss, forested_island, win_status})
    else
      :error ->
        {:reply, :error, state}

      {:error, :invalid_coordinate} ->
        {:reply, {:error, :invalid_coordinate}, state}
    end
  end

  defp player_board(state_data, player), do: Map.get(state_data, player).board

  defp opponent(:player1), do: :player2
  defp opponent(:player2), do: :player1

  defp update_player2_name(state_data, name), do: put_in(state_data.player2.name, name)

  defp update_board(state_data, player, board),
    do: Map.update!(state_data, player, fn player -> %{player | board: board} end)

  defp update_rules(state_data, rules), do: %{state_data | rules: rules}

  defp update_guesses(state_data, player_key, hit_or_miss, coordinate) do
    update_in(state_data[player_key].guesses, fn guesses ->
      Guesses.add(guesses, hit_or_miss, coordinate)
    end)
  end
end

Where Next? Top

Trending in Questions Top

stjefim
Hello! Suppose you are building workflow (order / task / payment) processing system with the following requirements: Each workflow con...
New
jonnycharles
I’m in search of an Elixir library that offers PDF generation capabilities similar to Ruby’s Prawn. While there have been discussions abo...
New
spammy
I’m looking to build a personal workflow to quickly deploy web applications written in elixir/phoenix, for local consumption (ie not on t...
New
dli
Before I dive in myself, did anyone successfully sprinkle Hologram into their existing LiveView app? Looking for hints regarding: Addi...
New
roeland
Kia ora, We have been using elixir-google-api to connect to Google Drive. However, with the updates to Tesla due to CVEs this is now bro...
New
bottlenecked
Hi all, I wanted to ask how the community is dealing with post-release steps. Today we have Ecto migrations, which make sure that the db...
New
rahultumpala
Hello, I have an Elixir backend that implements a custom protocol over TCP. I want to load test the backend and assess the performance o...
New

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
mcass19
ExRatatui lets you cook up rich terminal UIs in Elixir, powered by Rust’s ratatui via Rustler NIFs. Build interactive terminal applicatio...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New
ausimian
Emily is an Elixir library that runs Nx computations on Apple’s MLX. Install it as the default Nx backend and Nx, defn, Axon, Nx.Serving,...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews