Integrate a "pure functions" core with DB via Ecto

Hey guys,

I’m just starting with Elixir and I’m trying to create a small ‘core’ module to implement a logic of playing a game. I’m struggling with understanding whether/how I should use Ecto changesets as for input/return values for the functions in the ‘core’ module.

Ideally, I would like to make the ‘core’ unaware of the DB and of the caller’s needs. Right now I’m just going to use this module from a Phoenix context. But I’d like to implement a GenServer later for playing a game, using the same ‘core’ logic module.

This is what a function in that module can look like:

defmodule Game do
   def add_player_to_match(match, new_player) do
      # check if the new_player has already been added
      # add the new_player to match.players and return ???
      ...
   end
end

This function will be called by a module that will retrieve an existing ‘Match’ from the DB and save the results (match with a player added) back to the DB.

Is it ok for this function to accept an Ecto struct? Is it a good pattern for this function to return an Ecto changeset with the applied changes? This makes it super easy to save the result to the DB, but it kinda feels awkward. Also, in this case the unit tests of this function need to assert on the changes in the changeset, instead of the ‘business’ data itself. And piping these ‘core’ functions wouldn’t be possible either.

If this is not a good way to go, would you make the “core” functions work on pure (untyped) maps? Would you convert the Ecto struct to a Map using Map.from_struct/1 before passing it into the function? The way I see it could maybe work is something like this:

match = Repo.get!(123)
player = Repo.get!(456)
updated_match = Game.add_player_to_match(
  match |> Map.from_struct,
  player |> Map.from_struct
)

match
|> Ecto.Changeset.change(updated_match)
|> Repo.update

I would really appreciate some advice and/or pointers to articles/blogs about this kind of patters.

P.S. In case it’s relevant, these are the simple schemas (irrelevant fields redacted) I have for validations and interacting with the DB:

defmodule Match do
  use Ecto.Schema
  import Ecto.Changeset

  schema "matches" do
    field :finish, :utc_datetime
    field :start, :utc_datetime

    has_many :players, Player
    ...
  end
defmodule Player do
  use Ecto.Schema
  import Ecto.Changeset

  schema "players" do
    field :current_score, :integer
    belongs_to :match, Match
    ...
  end

Here’s a post that may be somewhat relevant (probably less for the data structure discussion but more for the migrating to GenServer bit coming later): https://www.theerlangelist.com/article/spawn_or_not

There is something that sits between an Ecto schema and a map - an Elixir struct (see defstruct in the docs) - it give your data a more defined shape than a pure map but without binding to the persistence mechanism. An Ecto schema is really just an Elixir struct with some extra fruit.

Depending on the whole scope I would most likely build this kind of thing using elixir structs for most of the data types going in and out of the core functions. I may start out with plain maps and lists while I’m iterating, but prefer structs for the extra safety net they provide (e.g. you can use pattern matching to ensure your core functions are only called with the correct data structure). I’d make the core function work how I really want them to work without considering persistence. Then I’d look at persistence and if it just so happened that one of the structs looks more or less like the database table that would be used for persistence I might upgrade that struct to an Ecto schema. I wouldn’t generally return a changeset from a core function - the persistence functions should be responsible for converting what the core functional layer is doing into something that can be pushed into the database.

There’s a bit of a discussion about generating changesets from plain structs here:

There is a also a highly relevant discussion in “Designing Elixir Systems with OTP” (Chapter 9) - a Pragmatic Programmer title (so you can probably get a discount). This also includes a pattern for dependency injection of the persistence mechanism if you want to take decoupling layers to the next level.

4 Likes

Thanks a lot, this helps!

I guess, I’m mostly worried about having to create a changeset from the original Ecto struct (returned by Repo) + the regular struct (returned by the ‘core’). Since Ecto.Changeset.cast needs a map with the updated attributes, not a struct, it seems like I will have to convert regular structs to maps in order for Ecto to produce a changeset to be used for the DB update.

I will try further, we’ll see how it goes :slight_smile:

This is by design. casting is meant to move external/user/not validated/weakly typed data into a known/broader typed/validated format. Such data is hardly ever found as structs, which at least prescribes a known format. For structs you can use any of the other functions in Ecto.Changeset, which apply changes without casting.

I’ve explained this a bit more in this blog post: https://lostkobrakai.svbtle.com/ecto-when-to-cast

2 Likes

Are you familiar with schemaless changesets? The result of apply_action on them is a map.