With Phoenix on a umbrella we lost the ability to join several functions together

I’ve started to refactor into a similar setup as @michalmuskala. I have the following setup in an umbrella app now:
db - Contains nothing but schemas and functions for composing queries (no side effects here)
services - All the real work is done here. Domain logic, database Repo.* functions, etc. Modules that build up Multis also live in this app.
api - The Phoenix app - with only controllers and views. I try to think of every controller action as doing the following: receive input, call a service, pattern match on the result, dispatch to view.

So, here’s an updated blog post implementation with this architecture:

# apps/db/post.ex
defmodule DB.Post do
  use Ecto.Schema
  import Ecto.{Changeset, Query}

  schema "posts" do
    belongs_to :user, DB.User
    field :title, :string
    field :body, :string
    timestamps
  end

  def changeset(post, params \\ %{}) do
    post
    |> cast(params, [:title, :body])
    |> validate_required([:title])
  end

  def where_title(query, title) do
    from p in query,
      where: p.title == ^title
  end
end
# apps/services/post_service.ex
defmodule Services.PostService do
  alias DB.{Post, Repo}

  @spec create(%DB.User{}, map) :: {:ok, %DB.Post{}} | {:error, %Ecto.Changeset{}}
  def create(user, params) do
    %Post{user_id: user.id}
    |> Post.changeset(params)
    |> Repo.insert()
  end
end
# apps/api/post_controller.ex
defmodule API.PostController do
  use API.Web, :controller
  alias Services.PostService

  def create(conn, params, user) do
    case Post.create(user, params) do
      {:ok, post} ->
        conn
        |> put_status(:created)
        |> render("post.json", post: post)

      {:error, changeset} ->
        conn
        |> put_status(:bad_request)
        |> render(ErrorView, "changeset.json", cs: changeset)
    end
  end
end

Want to create a post for a user from the REPL? It’s as easy as call Services.PostService.create/2, and you’re guaranteed to have all functionality from your controller wrapped up in one place.

Want to do something more complex? You’ve still used as many pure functions as possible to build up your service layer, so there’s no reason you can’t compose those functions differently to make more functionality.

We have one service that is substantially larger than the rest (and uses a lot of OTP features), so we pulled it out into it’s own app in the umbrella.

Note that refactoring to this service based organization was as easy as pulling out functionality from controllers, and replacing the use of conn with easy-to-pattern-match tuples, and throwing @specs in for easy future reference.

I’d love to hear if anyone has any feedback on this setup.

5 Likes