Search code examples
elixirphoenix-frameworkectoerlang-otpdecoupling

OTP and Ecto code separation in a modern Phoenix web app


After watching this talk, I understand how to separate the web interface and the OTP application, however how should the OTP app and Ecto code be separated, if at all?

Currently I'm writing an OTP app that calls Ecto functions, or wrapper functions for Ecto functions, within the handle_call/3 callbacks:

@doc """
Generates a workout.
iex> Pullapi.Database.delete_workouts()
iex> Pullapi.Database.delete_sets()
iex> result = Pullapi.GenServerWorker.handle_call({:initial_workout, 1, 20, 25}, nil, %{})
iex> {:reply, [{:ok, %Pullapi.Set{__meta__: _, action: action, id: _, inserted_at: _, order: _, units: _, updated_at: _}}| rest], %{}} = result
iex> action
"Pullups"
"""
def handle_call({:initial_workout, user_id, maxreps, goal}, _from, state) do
  # insert Goal
  %Pullapi.Goal{user_id: user_id, units: goal}
  |> Pullapi.Database.insert_if_not_exists


  # get Inital config
  config = Application.get_env(:pullapi, Initial)

  # retrieve id from inserted Workout row
  result = %Pullapi.Workout{user_id: user_id} |> Pullapi.Database.insert_if_not_exists

  case result do
    {:ok, workout} ->
        %Pullapi.Workout{__meta__: _, id: workout_id, inserted_at: _, updated_at: _, user_id: _} = workout

        inserted_sets = maxreps
        |> (&(&1*config[:max_reps_percent]/100  |> max(1))).()
        |> round
        |> Pullapi.Numbers.gaussian(
             config[:standard_deviation],
             config[:cap_percent],
             config[:cut]
           )
        |> Pullapi.Database.make_pullup_sets(workout_id)
        |> Pullapi.Database.add_rest_sets(config[:rest_intervals])
        |> Enum.map(&Pullapi.Repo.insert/1)

    {:error, _}  ->
        inserted_sets = []
  end

  {:reply, inserted_sets, state}
end

Is this approach coupling the two too tightly?

A database is used, as GenServer replies are calculated using previously generated, user-specific data - and I want the app to survive restarts.


Solution

  • Your code sample does not touch the GenServer state at all, which likely means it doesn't need to be inside the GenServer in the first place.

    Actually, putting it inside the GenServer may be a very bad idea, because you may be putting all of your database operations behind a single process, which will now become a bottleneck in your system.

    The general guideline here is to not use processes for code organization purposes but rather for when you need to express some runtime property, such as concurrency, global state or fault-tolerance.

    To answer your question more precisely, think of your domain API as regular modules and functions, that may need to talk to many processes to get the job done. The smaller and more focused those processes are, the cleaner the code will generally be. If you need a process to keep state, focus on its state, instead of adding business logic directly to it. If you need a process to act as a lock, implement the lock in isolation, decoupled from your use case and your domain. Etc, etc.

    The Spawn but not Spawn article can be helpful. The Adopting Elixir book, which I am a co-author, also explores these topics.

    EDIT: for your example in particular, you can move all of the code above to a single function called initial_workout/3 that receives user_id, maxreps, and goal as arguments and completely bypass the GenServer.