by Oleg Tarasenko
This blog suggests an alternative means by which you can structure your programs, inspired by the Elixir pipe macro â|>â but without making use of the dreaded parse transforms using my tiny epipe library, which I have recently written. Epipe itself is inspired by this article published by Scott Wlaschin.
Getting started
Letâs perform a small practical task which will demonstrate this railway approach to functional programming.
Consider the case where weâre building a POP3 email client using Erlang. Our goal is to implement a control flow for establishing connections with a POP server.
This diagram illustrates the steps needed to accomplish this action:
First, letâs build a function implementing the connection functionality:
connect(Addr, Port, ConnOptions, User, Password) -> {ok, Socket} = ssl:connect(Addr, Port, ConnOptions), ok = receive_greetings(Socket), ok = send_user(Socket, User), ok = send_password(Socket, Password).
The code above is very beautiful, only four lines of code and weâre done! But wait⌠the implementation above is very much a best case scenario. Obviously we need to add some error handling in order to to deal with edge cases :(. I mean, âwhat could possibly go wrongâ?
Adding error handling
Letâs summarise all possible edge cases on the diagram below:
Letâs add the error handling code, and see how it looks now!
Spoiler: The example below is trivial and can be beautified by splitting the operations into separate functions but the nested case statements are unavoidable.
connect(Addr, Port, ConnOptions, User, Password) -> case ssl:connect(Addr, Port, ConnOptions) of {ok, Socket} -> case receive_greetings(Socket) of ok -> case send_user(Socket, User) of ok -> case send_password(Socket, Password) of ok -> ok; _Err -> error_logger:error_msg("Auth error") end; _Err -> error_logger:error_msg("Unknown user") end; Err -> error_logger:error_msg("Could not receive_greetings") end; _Error -> error_logger:error_msg("Could not connect") end.
Wow. Now we have added all of the error code. And wow, the size of the code has increased by 400%⌠with a commensurate decrease in readability. Ouch!
Perhaps there is a cleaner way to implement this?
Designing for better errors handling with âthe railwayâ approach (theory)
The idea behind the railway approach is to decompose âstepâ functional blocks, using railway switches as an analogue:
* Image source: Scott Wlaschin
Which could be translated into the following Erlang code:
switch_component(Input) -> case some_action() of {ok, Response} -> {ok, Response}; % Green track Error -> {error, Error} % Red track end.
Once you have created two way (ok/error) switches for all required operations, you can combine them as elegant as itâs done on the railroad:
* Image source: Scott Wlaschin
So, to recap, what exactly happens is:
In the case of the success scenario, all functions (ârailway switchesâ) are executed sequentially, and we travel along the âSuccess trackâ. Otherwise, our train switches to the âError trackâ and travels along that route that way, bypassing all other steps:
* Image source: Scott Wlaschin
Designing for better error handling with âthe railwayâ approach
We have released a tiny erlang library, which simplifies railway decomposition for Erlang. So, given the above example, letâs take a look at how to implement our use case using Epipe:
-record(connection, { socket, user, addr, port, passwd}).connect(Addr, Port, User, Password) -> Connection = #connection{ user = User, passwd = Password, add = Addr, port = Port }, % Defining list of railway switches to follow ConnectionSteps = [ {get_socket, fun get_socket/1}, {recv_greetings, fun recv_greetings/1}, {send_user, fun send_user/1}, {send_passwd, fun send_passwd/1} ], % Running through switches case epipe:run(ConnectionSteps, Connection) of {error, Step, Reason, _State} -> error_logger:error_msg("Failed to establish connection. Reason: ~p", [Step]), {error, Reason}; {ok, _Conn} = Success -> Success end.% Building blocks. Note that every function can return either {ok, Connection} or {error, Reason}get_socket(Connection) -> case ssl:connect(Addr, Port, ExtraOptions) of {ok, Socket} -> {ok, Connection#connection{socket = Socket}}; Error -> {error, Error} end.recv_greetings(Connection) -> case recv(Connection) of {ok, <<"+OK", _Rest/binary>>} -> {ok, Connection}; {ok, <<"-ERR ", Error/binary>>} -> {error, Error}; Err -> {error, Err} end.send_user(Connection = #connection{user = User}) -> Msg = list_to_binary(User), send(Connection, <<"USER ", Msg/binary>>), case recv(Connection) of {ok, <<"+OK", _Rest/binary>>} -> {ok, Connection}; {ok, <<"-ERR ", Error/binary>>} -> {error, Error}; Err -> {error, Err} end.send_passwd(Connection = #connection{passwd = Passwd}) -> Msg = list_to_binary(Passwd), send(Connection, <<"PASS ", Msg/binary>>), case recv(Connection) of {ok, <<"+OK", _Rest/binary>>} -> {ok, Connection}; {ok, <<"-ERR ", Error/binary>>} -> {error, Error}; Err -> {error, Err} end.
The resulting code is not smaller in terms of lines of code when compared to nested case statements implementation, but it is certainly a lot more readable, making it much easier to debug and support.
If youâd like to see a real world implementation, please take a look at this refactoring example performed using the railway approach.
Originally published at www.erlang-solutions.com on June 13, 2018.