Functional Web Development with Elixir, OTP - Is OTP part of the entity layer?

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.

3 Likes

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?

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.

1 Like

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.

1 Like

@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?

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

4 Likes

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

2 Likes

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

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.

8 Likes

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
2 Likes

That is precisely the question :smiley:

I understand this point. However I still have the following question:

  • Given that the core should be functional, should we care about processes at all? Isn’t a process a runtime requirement that is independent from the functional requirements (the core of the application)?

I ask this, because when I used to work with Java (yes yes, I can hear you mocking me, that’s fine :P) we used to focus our application in the functional requirements, and only then if needed, we added threads for concurrency. I understand Java threads are a poor comparison to erlang processes but the idea I am trying to take out of here is how to organize workflow and to separate runtime concerns from other concerns.

I am not saying I don’t agree that we need a process. I think this is a sound solution that makes a whole lot of sense (and now that Supervisor follow, it makes even more sense) I am just asking if when you build application cores and, you already think about processes.

Perhaps this image helps explain my doubts:

So, I have the idea that Island, Guesses, Board, Coordinate, Rules and Game all belong to the core layer. OTP would be the layer on top, the Domain one, where we add processes, supervisors and support for fault tolerance / concurrency.

Perhaps I am miss-interpreting the lessons in the book, perhaps this onion model doesn’t really fit (perhaps both), but I am struggling to understand where each piece fits. This struggle is where my questions come from :smiley:

@peerreynders He he, yeah, I would personally separate them. But now I am keen on knowing the disadvantages of coupling to OTP behvaiours (topic for another discussion).

Thanks for the code though, that is exactly the module I was referring to !

Usually to satisfy some non-functional requirements. And when threads are introduced the structuring of the logic is impacted, sometimes severely.

In the end OTP is simply a standard library containing “formalizations of common patterns” of using the underlying virtual machine - it’s not some external system.

The other difference is that with threads you focus on flow of control while with processes you focus on the flow of messages (data). That will fundamentally impact how you structure your logic.

With the BEAM processes exist inside the core and domain of the onion diagram, just like in Java the threads package wouldn’t be pushed into the API layer (unlike lets say java.sql which is only used to communicate with an external system, a database).

Given that the core should be functional

Where is this coming from?

With the BEAM there is sequential programming and concurrent programming. Sequential programming is functional programming and happens strictly inside a process. Concurrent programming is about communicating processes.

The whole point of the BEAM is that you use both styles in concert.


Let’s turn this around. What in your view disqualifies a process from being an entity?

2 Likes

The fact that it mixes runtime concerns with functional concerns. So, how would you classify the structure of the Islands project in the book? Where would you put Island, Rules (state machine) Guesses, Board and the Game entity?

All together in the core? All together in the Domain? Separated (if so how?)
Perhaps you would use another Onion Diagram instead of the one I proposed?

Porting to Erlang is easy as Elixir more or less runs on top of Erlang and all the system building concepts like OTP come from Erlang.

When a method is used to access or mutate a class instance it does so under the flow of control of a thread implicitly. It’s only after you decide that you need distinct flows of control i.e. multiple threads that they become explicit.

Process oriented design addresses these type of runtime concerns deliberately at design-time by always making them explicit.

Where would you put Island, Rules (state machine) Guesses, Board and the Game entity?

The domain. But you have to accept the fact that “entities” can manifest in different forms. In Java it may be a single class instance or an aggregation of instances. On the BEAM an entity could simply be represented as a data structure, a process, or multiple collaborating processes. As the earlier code has shown, you can view a process as an aggregate of its state and the process shell.

All together in the core? All together in the Domain?

The relationship between the Domain and the Core is muddy at best.

  • here the application core cuts across domain model, domain services, and application service.
  • here Application + Domain are lumped together.

So the nature of the boundary between Core and Domain isn’t exactly clear. The Core at times comes across as some kind of standard library of non-domain specific building blocks. So are we talking about arrays, lists, maps, etc? :man_shrugging:

Then on the BEAM processes are in the core, as is OTP.

3 Likes

My first impression here is that I’m wary of trying to apply ideas from other ecosystems wholesale onto the BEAM ecosystem. When I was new to Elixir, I did it, and I know it did not help me.

Part of the idea of the book is to explore what’s possible in this new world. There are things that we can do on the BEAM that you would be hard pressed to do in other languages/ecosystems. Trying to apply rules/approaches/schemes from those other ecosystems onto this one seems unhelpful.

My strong preference would be to look at what we’re doing in the book and judge it on it’s own merits rather than comparing it to other systems or trying to fit it into a paradigm from another ecosystem.

That’s me trying to say, let’s please leave Java at the door. :^)

I’m also reminded of that old joke about the best answers to software questions always beginning with, “It depends.”

To me, questions like the one we’re looking at come into the realm of design decisions based on the constraints of the individual project. As we have no doubt seen, the only constant in software work is change.

The most important thing, then, is to design things with flexibility in mind. The individual choices we make are important, but the ability to easily change what we’ve done seems to me to be even more important.

Early in the book, we talk about having the ability to run thousands and thousands of games on the same node. I take that a constraint on the system. We need to solve for that in the design.

You’ve agreed that when we build a GenServer, we’re really defining a module and functions that work on some data. The change is that we decided that those functions should all run in a separate process (or processes).

To me, that says that we’re still working with the same things we were working with when we defined Board, Island, Guesses, and Coordinate, but by making this piece a GenServer, we satisfy the need for many games on the same node.

By using GenServer, we just did the two things at once - define the entity with a module, some functions, and some data as well as allow us to run it in multiple processes.

Here’s where your personal choice comes in. If this makes you feel uncomfortable, you can absolutely use the code for an intermediary module that @peerreynders wrote up. All will be well.

To me, the key point is that the design we have in the book makes that refactor a straight forward affair. You could do it wholesale. You could do it one api function/callback at a time.

It’s your choice. You can do it in response to changing requirements. You can manage the transition safely.

8 Likes

After reading everyone’s replies, I must say I am confused.

I understand we ought to leave Java at the door - I find that perfectly reasonable and I agree (after all, Elixir/Erlang has a different kind of OO than Java because they were more influenced by SmallTalk and their first version/prototype was done in Prolog iirc).

But at the same time, I find it very hard to understand, that a decade of architectural design patterns should just be thrown out of the window because Elixir is new. I mean, surely there are some concepts, some links we can make from the most common layered architectural design patterns right? Sure, maybe the onion one is a terrible pattern to apply in Elixir, perhaps the hexagonal one is way better because it only has 3 zones (application, DMZ where the adapters are and the OHW, aka, outside horrible world) but we should still have some sort of resemblance going on.

I say this, mainly because I am with Pragmatic Dave when he says “Building applications in INSERT_TECHNOLOGY_X_HERE doesn’t change how good applications are built because the basic good core tenets of architecture are the same no matter which technology you use”.

I am not saying the book example is lacking a good architecture. I am just saying I am personally having trouble comparing the tenets of well known architectural patterns to the example and connecting the dots.

This is not a problem with the book, this is a problem with me, I am very well aware of that - I am quite obsessed with design and architecture and the main reason I want to get it right in Elixir is because I have been through years and years of suffering from bad design choices made by others and I have grown honestly rather tired. I am just trying to keep that cycle from repeating itself and I know that starts with me, but first I need to understand where all the pieces fit together.

In your opinions, is there any architectural pattern that somehow resembles what the example in the book follows? Is this is a new approach that is seen in most Elixir applications and that I missed because I am still learning Elixir?

1 Like

I guess I’m confused what you mean when you say the “entity” layer. In the context of the book, the games are built as data structure. They take in new data and provide a new data structure back to you. In order to take these data structures and make them into a useful game we put the data structure in a process and allow the process to manage the lifecycle of that game. I’m really not sure where the hangup is I guess.

As to throwing things out, I don’t think anyone is throwing out any established architectural patterns here. The book provides an alternative to all of the patterns you mentioned. At least I think it does. Out of all the patterns you’ve mentioned the only I’d ever heard of was “hexagonal” (a pattern that I’m not a fan of tbh). I don’t know what “onion” or “lasagna” architectures are but I guess they involve layers?

Either way I don’t think that you should take the book as the end all be all to design. If you want to just put stuff in a database that’s quite alright and that concept will generally scale well and lead to less stateful designs. But if you need to bring state into your system then the book shows you a way to layer together a functional interface with stateful processes.

5 Likes

Is there a “shortage” of Erlang/Elixir patterns? Maybe, maybe not:

http://erlang.org/pipermail/erlang-questions/2007-October/030091.html

Garrett Smith attempted an Erlang specific repository but contributions stalled.

And even if a prescriptive pattern was followed - it could still be wrong if it is applied in the wrong context (this is a real problem in OOP with respect to design patterns).

Ultimately Game is just a process manipulating some data structures (Board, Coordinate, Guesses, Island, Rules) - and Game itself is going to be part of a supervision tree which itself is part of an OTP application.

For a discussion on how to structure supervision trees: The Hitchhiker’s Guide to the Unexpected
Data structure vs Process: To spawn, or not to spawn?
Structured Programming Using Processes (2004)

Building applications in INSERT_TECHNOLOGY_X_HERE doesn’t change how good applications are built

Yes, but going from Java to C# isn’t changing the paradigm so the solution shape isn’t impacted. But going to Erlang/Elixir you are shifting paradigms into “Concurrency Oriented Programming” which means that processes and most of OTP becomes part of the Core rather than being a

What seems to currently be challenging you the most is that a process can be an entity.

I’ve commented on the onion architecture before and in my view the most insightful aspect was that the domain layer gets to dictate the contracts while the infrastructure has to implement those contracts (e.g. nothing in the data store is influencing what is going on in the domain).

That particular advice still applies to Elixir - if that is the type of system that is being built.

Most of these architectures boil down to examining the structure and characteristics of a system along certain dimensions:

  • Boundaries
    • at all levels of granularity (types, user defined types (modules), processes, supervision trees, applications, systems)
    • physical vs logical boundaries
    • (high) cohesion of logic within any one boundary
    • (low) coupling between boundaries
      • dependencies from within the boundary
      • dependencies on the boundary
      • i.e. good inter-boundary contracts (hiding implementation details, correct (specification -> implementation) direction, etc.)

Alongs those lines what are the problems that you see with the design of Game?

Now there are some games you can play to hide whether or not something is a data structure or a process. But processes that are part of a supervision tree are known to be processes - otherwise they couldn’t be part of the tree.

6 Likes

First, I want to echo what @keathley and @peerreynders had to say. They covered a lot of good ground pretty thoroughly, so I’m going to take another direction in trying to answer your question.

I’d like to start by making a distinction between design patterns and design principles.

The Gang of Four book is probably the most famous source of design patterns, but the architectural patterns you mentioned - hexagonal and onion - are as well. My sense of design patterns is that they’re fairly strict. There are rules to follow. There are definitions and technical terms to learn.

Design principles are more loose and general. These are often just phrases like “build in layers”, “minimize complexity”, “name things expressively”, and so on.

Design principles most often use common terms with their everyday meanings. They tend not to emphasize hard and fast rules, but this doesn’t make them any less useful. In fact, their flexibility makes them applicable in a broader range of cases.

That flexibility gives the developer a lot of discretion, but it requires the developer to exercise more of their own judgement as well.

Here’s where this applies to the concern you raised that we might be throwing hard won knowledge out the window.

When folks started to write Gang of Four style books for other languages (GoF was originally written for C++ iirc), they needed to change and adapt the patterns. Sometimes they needed to make considerable changes. Sometimes they needed to throw patterns away completely because they didn’t make any sense in the new target language. (I’m thinking of the Ruby one especially.)

This reinforces the idea that patterns aren’t very flexible. It’s really hard to apply them across languages. It’s way harder to apply them across the OOP/FP border.

Principles are much more likely to make that trip successfully. My suspicion is that in the line you quoted from Dave Thomas, he’s talking about something closer to principles than patterns. (But that’s only my suspicion, and I don’t want to put words in Dave’s mouth.)

Also in this respect, Elixir isn’t really new, as you say. Elixir semantics are very close to Erlang’s, and Erlang has been around for decades. Elixir and Erlang are just different from most other paradigms. So this idea that we can readily apply patterns, not principles, from OOP seems really unlikely to me.

4 Likes