Railway Oriented Programming in Elixir with Pattern Matching on Function Level and Pipelining

Mustafa Turan
Feb 25, 2017 · 4 min read

Coding seems cool without error checks, does not it? When if/else checks effect the next execution behaviours, then the code becomes a mess. Luckily, there is a pattern called ‘Railway Oriented Programming’ for error handling. It simply helps you to focus on the happy path with SOLID principles like single responsibility and open-close while errors follow the failure pipe. Elixir language has ‘pattern matching on function level’ and ‘pipelining’ to apply ‘Railway Oriented Programming’ pattern.

Image for post
Image for post

With Elixir’s ‘pattern matching’ and ‘reusable function names’, you can define the same function with different parameters with same and different arities. Let’s see what that means with a user login sample. For example, I need ‘email’, ‘password’ parameters inside ‘user’ key as a request body in other cases return an error message. This means that I can define two function with same name, same arity and different parameter keys.

  def login(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
LoginPolicy.process(user_params)
end
def login(conn, _) do
@renderer.render(conn, :unprocessable_entity, %{errors:
%{details: "Missing email or password data!"}})
end

Assume that ‘user login’ action delegates all business logic to the LoginPolicy. And business logic needs to be sure that 1) given email exists, 2) password matches with encrypted password in DB, 3) check user’s email confirmed if needed, 4) check one-time password token if needed and activated by that user and 5) lastly create a session token in DB. In any of these checks gives an error then returns that error with reasonable http_status_code and error message.

Normally, without Railway Oriented Programming, what can be done in Elixir is to use conditional checks with if/else or case statements. Ok, let’s implement this and see how it looks like. Since there is no ‘return statement’, you need to use ‘else’ clauses.

defmodule Shield.Policy.User.Login do
...
def process(params) do
user = @repo.get_by(@user, email: email)
if user do
case CryptUtil.match_password(password, Map.get(user, :password, "")) do
false ->
{:error, {:unauthorized, %{password: ["Wrong password!"]}}}
true ->
is_email_confirmation_required = Application.get_env(:shield, :confirmable)
is_email_confirmed = Map.get(user.settings || %{}, "confirmed", false)
case {is_email_confirmation_required, is_email_confirmed} do
{true, false} ->
{:error, {:unauthorized, %{email: ["Email confirmation required."]}}}
_ ->
case OneTimePasswordArm.find_otp_secret_token(user) do
nil ->
{:error, {:unauthorized, %{otp_secret_token: ["Not found"]}}}
token ->
otp_value = Map.get(params, "otp_value", "")
case OneTimePasswordArm.is_valid?(token.value, otp_value) do
false ->
{:error, {:unauthorized, %{otp_value: ["Invalid one time password"]}}}
true ->
changeset = @token_store.session_token_changeset(%@token_store{},
%{user_id: user.id, details: %{"scope" => "session"}})
case @repo.insert(changeset) do
{:ok, token} ->
{:ok, Map.put(params, "token", token)}
{:error, changeset} ->
{:error, {:unprocessable_entity, changeset}}
end
end
end
end
end

else
{:error, {:unauthorized, %{email: ["Email could not found."]}}}
end
end

In ‘Railway Oriented Programming’, the happy path follows success path and failure path follows error path. In this sample, I used {:error, {http_status_code, errors}} for error path and {:ok, data} for success path. For the error path simply create a function same name as the success path function and pass through errors without modifying it. Let’s dive into code:

defmodule Shield.Policy.User.Login do
...
def process(params) do
params
|> validate_email_exists()
|> validate_email_password_match()
|> validate_email_confirmation()
|> validate_one_time_password()
|> insert_session_token()

end
defp validate_email_exists(%{"email" => email} = params) do
case @repo.get_by(@user, email: email) do
nil -> {:error, {:unauthorized, %{email: ["Email could not found."]}}}
user -> {:ok, Map.put(params, "user", user)}
end
end
defp validate_email_password_match({:ok, %{"user" => user, "password" => password} = params}) do
case CryptUtil.match_password(password, Map.get(user, :password, "")) do
false -> {:error, {:unauthorized, %{password: ["Wrong password!"]}}}
true -> {:ok, params}
end
end
defp validate_email_password_match({:error, opts}), do: {:error, opts}
defp validate_email_confirmation({:ok, %{"user" => user} = params}) do
is_email_confirmation_required = Application.get_env(:shield, :confirmable)
is_email_confirmed = Map.get(user.settings || %{}, "confirmed", false)
case {is_email_confirmation_required, is_email_confirmed} do
{false, _} -> {:ok, params}
{true, true} -> {:ok, params}
{_, _} -> {:error, {:unauthorized,
%{email: ["Email confirmation required."]}}}
end
end
defp validate_email_confirmation({:error, opts}), do: {:error, opts}
defp validate_one_time_password({:ok, %{"user" => user} = params}) do
is_otp_check_required = Application.get_env(:shield, :otp_check)
is_otp_check_enabled = Map.get(user.settings || %{}, "otp_enabled", false)
case {is_otp_check_required, is_otp_check_enabled} do
{nil, _} -> {:ok, params}
{false, _} -> {:ok, params}
{true, false} -> {:ok, params}
{true, true} -> validate_one_time_password_value({:ok, params})
end
end
defp validate_one_time_password({:error, opts}), do: {:error, opts}
defp validate_one_time_password_value({:ok, %{"user" => user} = params}) do
case OneTimePasswordArm.find_otp_secret_token(user) do
nil ->
{:error, {:unauthorized, %{otp_secret_token: ["Not found"]}}}
token ->
otp_value = Map.get(params, "otp_value", "")
case OneTimePasswordArm.is_valid?(token.value, otp_value) do
false -> {:error, {:unauthorized,
%{otp_value: ["Invalid one time password"]}}}
true -> {:ok, params}
end
end
end
defp insert_session_token({:ok, %{"user" => user} = params}) do
changeset = @token_store.session_token_changeset(%@token_store{},
%{user_id: user.id, details: %{"scope" => "session"}})
case @repo.insert(changeset) do
{:ok, token} ->
{:ok, Map.put(params, "token", token)}
{:error, changeset} ->
{:error, {:unprocessable_entity, changeset}}
end
end
defp insert_session_token({:error, opts}), do: {:error, opts}
end

What happens in the ‘process’ function is; first calls validate_email_exist/1 with given params and this function checks the ‘email’ exist or not. Then it returns error or success. Next function validate_email_password_match/1 is called with errors if an error results on the previous function and passes the error as is. All the other functions do the same and the final result will be returned from the ‘process’ function to the ‘caller’.

I also recommend watching Rob’s talk and reading Zohaib’s Elixir macro approach on ‘Railway Oriented Programming’. Lastly, you can read the sample codes for the login_policy.

Note: Please be aware that I am not recommending using Function Level monads in Elixir, instead it is recommended to use `with` clause which is available in Elixir lang since 2016. Please consider this story to understand how to simplify things in Elixir using several ways.

Happy coding with ♥!

ElixirLabs

Elixir, OTP, Phoenix Framework, Ecto

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store