Service modules in Phoenix app
Jun 3, 2019 · 907 words · 5 minutes read
Probably one of the more significant changes in recent Phoenix versions were Contexts. They encourage developers to think about the application’s structure, design. They help to group related functionality, encapsulate logic, and split concerns from the “web” layer. That’s GREAT, it’s awesome. Generators by default put both “read” and “write” inside contexts and this is one of the annoyances I’ve had with them. I found the write side to grow out of hand quickly as new requirements come in and it’s easy to fall into adding “just one more line” and wake-up when you have 2k+ LOC modules.
As with good software design, you will try to extract the logic into separate modules with clear boundaries but it may be too early, or maybe the smaller context is still unclear, or maybe you don’t want to, which is fine too.
defmodule MyApp.Accounts do
alias MyApp.Accounts.User
def register_user_chset(params) do
User.register_changeset(params)
end
def register_user(params) do
User.register_changeset(params)
|> Repo.insert()
end
end
Now let’s say, another requirement comes in and you need to publish event after registration. Now, it may be become something like this:
....
def register_user(params) do
Multi.new
|> Multi.insert(:user, User.register_changeset(params))
|> Multi.run(:analytics, fn _, %{user: user} -> Analytics.user_registered(user) end)
|> Repo.transaction()
end
You may also want to send a confirmation email, set up rest of the account or something else.
If it’s all encapsulated inside register_user/1
, it’s not that bad, but you can see where it’s going.
In the beginning, I’ve started decomposing them into smaller functions, then into separate modules but I still wanted to have related functionality in one place. I’ve started putting everything into separate, named by the action, module. Also known as Service Objects. The API was pretty much the same for most of the actions. Sometimes there was additional “new/2” method for initial form changeset. Then, I’ve stumbled upon (Ab)using Ecto as Elixir data casting and validation library and it beautifully resolves some of the problems I’ve had with single module approach where there was quite a bit of boilerplate. I liked the solution, and it worked really well when I started using it for new actions.
On one peaceful evening, I’ve decided to extract a library from all common parts and came up with a thing called FireAct.
It’s API is super small, it’s basically a function run/2
that takes action handler, params and optionally initial assigns as the 3rd argument.
It always returns a tuple with the result code and action
(very similar to Plug
where each plug always returns Plug.Conn
, more on that later).
The simplest action may look like this:
defmodule MyApp.Foo do
use FireAct.Handler
def handle(action, _params) do
action
|> assign(:foo, "bar")
end
end
and run via:
params = %{}
{:ok, %{assigns: %{foo: "bar"}}} = FireAct.run(MyApp.Foo, params)
or maybe you’d like to mark the action as failed, then you could
....
action
|> assign(:foo, "bar")
|> fail()
In this case you’d receive:
{:error, %{assigns: %{foo: "bar"}}} = FireAct.run(MyApp.Foo, params)
This stable interface makes it easier to integrate it in other places like GraphQL mutations or reuse the same error handling in controllers.
I’m also a fan of Ecto.Changeset. It’s probably one of the most visited websites by me on hexdocs, and it’s not because there’s something wrong with the library, it’s because it’s so good! Its documentation is clear, precise, and always helpful. I wanted FireAct to help with params validation via Ecto.Changeset too.
Simplified production code from one of the apps:
defmodule Mf.Accounts.Action.ChangePersonalInfo do
use FireAct.Handler
use FireAct.ChangesetParams, %{
name: :string,
locale: :string
}
alias Mf.Accounts
def new(%{user: user}), do: cast(Map.take(user, [:name, :locale]))
def handle(action, data) do
action.assigns.user
|> Accounts.User.changeset(data)
|> Repo.update()
|> case do
{:ok, user} ->
action
|> assign(:user, user)
# fail loudly
end
end
def validate_params(_ctx, changeset) do
changeset
|> validate_required([:name, :locale])
|> validate_length(:name, max: 32)
|> validate_inclusion(:locale, Mf.Accounts.supported_locales())
end
end
With the help of FireAct.ChangesetParams
, when handle/2
is executed,
params are already validated, and you can directly and safely use them to update the user.
Changeset validations are in the same module, clearly visible.
If I had another form to update user, maybe with a different set of validations,
I’d not have to modify Mf.Accounts
or Mf.Accounts.User
, I’d add another action.
Sometimes I needed to load additional data before I could do any validations.
When I had used actions only in Phoenix controllers, everything was great -
I preloaded data inside controller and then passed that as a context.
When I started writing GraphQL layer, I needed to do the same. I didn’t want to repeat the same logic inside the resolvers.
I also wanted to reuse the same familiar interface I had in Phoenix controllers via plugs
.
I’ve added supported for that based on
Plug.Builder
.
defmodule Mf.Auth.Action.Login do
use Mf.Core.FireAction
plug(:preload_user)
use FireAct.ChangesetParams, %{
email: :string,
password: :string
}
....
defp preload_user(action, _) do
email = action.params[:email]
user =
if email do
Accounts.get_user(email: email)
else
nil
end
action
|> assign(:user, user)
end
I’ve been using this pattern in a smallish application, excluding tests, a bit over 10k LOC.
Language Files Lines Code Comments Blanks
---------------------------------------------------------------------
Elixir 301 14544 11702 401 2441
> rg "FireAction" --vimgrep | wc -l
42
It works really well and scales nicely as the application grows. FireAct actions remind me of CommandOrientedInterface, although in this case, there’s no “Interface”. In the future I want to look into enforcing the interface via Behavior.
In case you have any comments or questions - feel free to contact me at email.