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

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 Credit— params |> validate() |> update_db() |> send_email()

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.

Update: My meetup talk slides

Happy coding with ♥!