Phoenix framework is a web framework for Elixir programming language, which became the go-to set of libraries when we start new project for our clients. It’s performant and robust, flexible and has sane defaults - we enjoy working with Phoenix a lot.
However nice and programmer-friendly the framework may be, there is always other pieces that build your application, and most important of these is your own code.
Code refactoring is a critical step during development of applications here at AmberBit. Whenever we have the pull request ready to be submitted and reviewed by other developers, we usually take a last look at the code we wrote and decide what to refactor and how. Everyone likes pretty code!
One of the most usual suspects that get picked as a part that needs refactoring are Phoenix controllers. Let’s have a look at several techniques we use to make our code look pretty.
Technique #1: Extract logic to Plugs
Plug is a library that Phoenix build on top of, and it provides basic interface to deal with HTTP requests and web servers in Elixir applications. Good place to start learning about Plug is this section of ElixirSchool.
Whenever you have code which repeats in multiple controller actions or simply has to be executed before each action - creating a custom plug that does the required job is the way to go.
Let’s have a look at the following controller:
defmodule MyApp.Web.PostsController do
use MyApp.Web, :controller
def index(conn, _params) do
conn = conn
|> assign(:current_user, find_current_user(conn))
case conn.assigns.current_user do
nil ->
conn
|> put_flash(:info, "Please sign in!")
|> redirect(to: login_path(conn, :new))
|> halt()
_user ->
conn
|> render("index.html")
end
end
...
defp find_current_user(conn) do
...
end
end
We want to make sure we show the page only to signed in users. In all other cases we want to redirect to login page.
It’s fine to have such code in one place of application, maybe two - but
if you have to call find_current_user/1
and conditionally branch off
your program flow with case
or another conditional syntax construct in
every single controller and every single action (except login page) this
quickly becomes tiresome.
The way I like to break it down is to introduce not one - but two custom plugs: one which finds currently signed in user, and another one that redirects user to login page if user is not signed in. This give us flexibility to have pages where being signed in is not required - but it can enable personalized / additional functionality - such as adding comments, and have other pages that require user to be signed in.
defmodule MyApp.Plugs.FindCurrentUser do
import Plug.Conn
def init(_opts) do
:ok
end
def call(conn, _) do
conn
|> assign(:current_user, find_current_user(conn))
end
defp find_current_user(conn) do
...
end
end
defmodule MyApp.Plugs.RequireCurrentUser do
import Plug.Conn
def init(_opts) do
:ok
end
def call(conn, _) do
case conn.assigns[:current_user] do
nil ->
conn |> redirect(to: login_path(conn, :new)) |> halt()
_user ->
conn
end
end
end
There are (at least) two ways to use our brand new plugs in controllers:
- Directly instructing Phoenix to execute them - form within controllers themselves.
- By adding these plugs to a pipeline in router
Let’s see how our controller looks like if we go with option 1:
defmodule MyApp.Web.PostsController do
use MyApp.Web, :controller
plug MyApp.Plugs.FindCurrentUser
plug MyApp.Plugs.RequireCurrentUser
def index(conn, _params) do
conn
|> render("index.html")
end
end
Better? Better.
In case you want to exclude plugs executing from certain controller actions - or selectively specify before which actions plug should be executed, you can add guard clauses directly in your controllers too.
Usually more handy and less error-prone way than adding plugs to your stack in controllers is to alter your router to do so for you:
defmodule MyApp.Router do
use MyApp.Web, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MyApp.Plugs.FindCurrentUser # add this line
end
pipeline :require_current_user do
plug MyApp.Plugs.RequireCurrentUser
end
...
scope "/", MyApp do
pipe_through [:browser]
get "/login", LoginController, :new
...
end
scope "/", MyApp do
pipe_through :browser
pipe_through :require_current_user
resources "/posts", PostsController
...
end
...
end
As you can see above, you can alter your standard :browser
pipeline to
include plug that finds and assigns current user to
conn.assigns.current_user
, and create additional pipeline which
ensures user is signed in. All the routes defined in the second scope
block will redirect to login page if user is not signed in.
This way you can easily separate your public pages from these that require being signed in to account in your system.
The same technique can be applied to several other cases, such as:
- finding and assigning parent resources in case of nested resources
such as
/posts/:post_id/comments/:comment_id
- introducing authorization / permission checking based on controller/action/role given user has in the system
- setting language / locales based on cookie
- finding current tenant in multi-tenant system based on domain/subdomain
and many other similar use cases.
Technique #2: Extract logic to workflows (a.k.a. services)
Whenever your controllers become bloated, and you have multiple helper functions in them - good idea may be to move the business logic to separate modules. Doing so helps with testability and code re-use, but most importantly leaves the controllers to do one thing and one thing only: control how and what to render, depending on the outcome of some business logic process.
Personally I think best course of action, for most controllers and actions, is to delegate as much as you can, possibly each action - to it’s own module / function that defines it’s behavior.
Let’s consider the following controller:
defmodule MyApp.Web.RegistrationController do
use MyApp.Web, :controller
plug MyApp.Plugs.RequireNoUser # custom plug we learned above about
plug :put_layout, "no_user.html"
def new(conn, _params) do
conn
|> render("new.html", changeset: MyApp.User.changeset(%MyApp.User{}, %{}))
end
def create(conn, %{"registration" => registration_params}) do
user_changeset = MyApp.User.changeset(%MyApp.User{}, registration_params)
case MyApp.Repo.insert(user_changeset) do
{:error, changeset} ->
conn
|> render("new.html", changeset: changeset))
{:ok, user} ->
user
|> seed_example_dashboard_data()
|> send_email_confirmation_request()
|> send_admins_new_user_notirication()
conn
|> sign_in_user(user)
|> redirect(to: dashboard_path(@conn, :show))
end
end
defp send_email_confirmation_request(user) do
...
defp send_admins_new_user_notification(user) do
...
defp sign_in_user(user) do
...
defp seed_example_dashboard_data(user) do
...
end
It’s a pretty standard signup controller, handling new user registration and onboarding. While it’s pretty standard - there is still plenty of things going on. Not only we validate the form (here using directly changeset and Ecto schema backed by database), but after user registered we have to seed their dashboard with example data, send them confirmation e-mail with a link, send notification to admins and sign user to the system. This could easily mean this single controller has already 200 lines of code or more. And that’s just a single action.
How I like to deal with bloated controllers is moving the business logic to reusable services. I have noticed these services often take a form of multi-step algorithms, and for a while I adopted naming convention of calling them “workflows”. I must admit, I stole the naming from another Elixir developer, but I think I also infected others with the idea since, which suggests it’s not terribly bad.
Let’s see how can we extract the business logic to a workflow:
defmodule MyApp.Registration.Workflow do
def run(registration_params) do
with user_changeset <- MyApp.User.changeset(%MyApp.User{}, registration_params),
{:ok, user} <- MyApp.Repo.insert(user_changeset),
:ok <- seed_example_dashboard_data(user),
:ok <- send_email_confirmation_request(user),
:ok <- send_admins_new_user_notirication(user) do
generate_sign_in_token(user)
end
end
defp send_email_confirmation_request(user) do
...
defp send_admins_new_user_notification(user) do
...
defp generate_sign_in_token(user) do
...
defp seed_example_dashboard_data(user) do
...
end
And we can use it in controller like this:
defmodule MyApp.Web.RegistrationController do
use MyApp.Web, :controller
plug MyApp.Plugs.RequireNoUser # custom plug we learned above about
plug :put_layout, "no_user.html"
def new(conn, _params) do
conn
|> render("new.html", changeset: MyApp.User.changeset(%MyApp.User{}, %{}))
end
def create(conn, %{"registration" => registration_params}) do
case MyApp.Registration.Workflow.run(registration_params) do
{:error, changeset} ->
conn
|> render("new.html", changeset: changeset))
{:ok, sign_in_token} ->
conn
|> sign_in_user_with_token(sign_in_token)
|> redirect(to: dashboard_path(@conn, :show))
end
end
defp sign_in_user_with_token(token) do
...
end
Better? I think so. Our controller is not concerned with generating token, sending e-mails nor dealing with database - it delegates all the hard work to dedicated workflow, while being focused on being a bridge between framework and your business logic. It’s almost like controllers are piece of infrastructure, and you can stop thinking about them as part of your “core” applications. Workflows/services are where the logic lives.
The bonus part about extracting business logic to workflows is that they are testable independently from controllers and Phoenix itself. You can test them in isolation.
Writing tests for workflows/services is usually easier and quicker to writing full controller tests or writing tests with something like Selenium/Wallaby combo.
What I like to do on the testing side of things is to write “happy path” tests using Wallaby, and leave edge cases to be tested with unit tests testing workflow itself. In the case of our example registration workflow and controller, I would write these Wallaby tests:
defmodule MyApp.Integration.RegistrationTest do
...
test "should register and sign in user if they filled in form correctly"
test "should display validation errors and not register user if the form is incorrect"
end
and then add fasts tests that do not rely on real browser to test some edge cases:
defmodule MyApp.Registration.WorkflowTest do
...
test "should insert new user to database after successfull registration"
test "should generate and send e-mails to user and admins"
test "should generate sign in token to be stored in the cookie"
test "should require e-mail, first and last name"
test "should require e-mail in valid format"
test "should make sure e-mail is unique in the system"
end
Technique #3: Using tableless Ecto schemas for custom form validations
In the examples above we have extracted some business logic to custom
plugs, and then extracted more business logic to workflows. Our
registration workflow is now dealing with database directly to perform
Repo.insert(changeset)
, but we still have to initialize the form with:
...
def new(conn, _params) do
conn
|> render("new.html", changeset: MyApp.User.changeset(%MyApp.User{}, %{}))
end
...
This is not ideal for two reasons:
Our controllers need to know about our Ecto schemas. You can argue that’s not a big issue, and I’ll agree, but it’s always nice to have separation of layers in your application where upper layer does not necessarily reach the bottom layer, but goes through the intermediary. In our case we have introduced workflow/services layer that we use
create
action, but not innew
.We are cluttering our Ecto schemas with business logic. This is a bigger problem, because it can possibly lead to security issues, where we unwillingly expose some database fields to be public where in fact we want them to be protected from user writes.
Let’s focus on reason 2 for a while. If our User
model has is_admin
field, which determines their system-wide administrative rights, and our
User.changeset/2
function allows setting this field, we have a
possible security vulnerability in our registration form. Even though
the form we prepared has no field for is_admin
in it, it doesn’t mean
a hacker can’t insert a field in the form. This can be as simple as
right-clicking in Chrome on the page, going to Developer Tools and
writing a single line of code to our HTML form:
<input type="hidden" name="user[is_admin]" value="true" />
User can register and obtain illegitimately admin rights. Oops. Yup.
There are several ways to deal with that. You could create a changeset
that does not accept is_admin
field. But then, you may need to have a
place where user can grant these permissions to others. If this need
arises, you end up with two changeset functions, that you use from
different parts of the code.
While the solution with multiple changesets is okay, I like to introduce custom, tableless schemas to take care of parameters filtering and validation.
We can extract the type casting and form validation from our MyApp.User
module to something like MyApp.Registration.Form
and use it in our
workflow and controller.
defmodule MyApp.Registration.Form do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field(:first_name, :string)
field(:last_name, :string)
field(:email_address, :string)
field(:short_bio, :string)
end
@required_fields [:email_address, :first_name, :last_name]
@optional_fields [:short_bio]
def changeset(attrs) do
%__MODULE__{}
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_format(:email_address, ~r/(\w+)@([\w.]+)/)
|> validate_not_already_registered()
end
defp validate_not_already_registered(changeset) do
with nil <- changeset.errors[:email_address],
%MyApp.User{} = user <- find_existing_user_by_email(changeset.changes.email_address)
changeset
|> add_error(:email_address, "is already taken")
else
_ ->
changeset
end
end
defp find_existing_user_by_email(email) do
...
end
defmodule MyApp.Web.RegistrationController do
...
def new(conn, _params) do
conn
|> render("new.html", changeset: MyApp.Registration.Form.changeset(%{}))
end
...
end
In workflow itself we have to do one more thing in order the Phoenix
helpers to show validation errors: set the action
field on the
changeset manually, but other than that we can just take
changeset.changes
from it and feed directly to User.changeset
:
defmodule MyApp.Registration.Workflow do
def run(registration_params) do
with %{valid?: true} = form_changeset <- MyApp.Registration.changeset(registration_params),
db_changeset <- MyApp.User.changeset(%MyApp.User{}, form_changeset.changes),
user <- MyApp.Repo.insert!(db_changeset),
:ok <- seed_example_dashboard_data(user),
:ok <- send_email_confirmation_request(user),
:ok <- send_admins_new_user_notirication(user) do
generate_sign_in_token(user)
else
%{valid?: false} = changeset ->
{:error, %{changeset | action: :create}}
error ->
error
end
end
...
end
And, quite likely we want to alter the form template to specify “as” option for the form helper:
<%= form_for @changeset, Routes.registration_path(@conn, :create), [as: :user, method: :post], fn f -> %>
...
Final thoughts
We like to keep our controllers and Ecto schemas lean and as much business-logic-free as possible. Both of these parts of application are where your own code deals with infrastructure. It makes sense from code reliability, security and ease of future upgrades to make them as simple as possible - and move your business logic to some other place.
Refactor bravely and refactor often, my Elixir friends :).
Post by Hubert Łępicki
Hubert is partner at AmberBit. Rails, Elixir and functional programming are his areas of expertise.