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 @spec
s in for easy future reference.
I’d love to hear if anyone has any feedback on this setup.