I’ve found myself implementing the command pattern in Elixir many times over across various projects, often with slightly different implementations. This library standardizes a lot of best practices I’ve found, with a foundation that can grow into a really powerful tool.
What is the command pattern? Only one of the most awesome patterns ever. Imagine wrapping all of your relevant params, data and errors into a struct that can be easily piped. Related business logic can live together in a single module file, instead of dirtying up Phoenix controllers or Ecto models. This library closely resembles Plug and Ecto changesets but with one key difference-- because everything is a module struct, you can easily implement all kinds of protocols for different flows of business logic.
Example
Getting started is easy. A command module might look like this…
defmodule RegisterUser do
import Commandex
command do
param :email
param :password
data :password_hash
data :user
pipeline :hash_password
pipeline :create_user
pipeline :send_welcome_email
end
...pipeline functions go here
end
The command/1
macro automatically defines new/1
and run/1
functions on the module, as well as a struct with the given attributes of the block. Params are what’s given to the new/1
function, and it can take either a keyword list or a string/atom key map. Data fields are things generated over the course of running a command, and can be set with put_data/3
.
Pipeline functions take three arguments (command, params, data), and must return a command. Structuring it this way makes for super simple pattern matching:
def hash_password(command, %{password: nil} = _params, _data) do
command
|> put_error(:password, :not_given)
|> halt()
end
def hash_password(command, %{password: password} = _params, _data) do
put_data(command, :password_hash, Pbkdf2.hash_pwd_salt(password))
end
Pipeline functions are run in the order in which they are defined. And remember, like Plug, Commandex will continue running through the pipeline unless you call halt/1
. This allows for intelligent error handling further down the pipe.
Calling a function outside of the module? That’s easy. The following three definitions are equivalent…
pipeline :hash_password
pipeline {RegisterUser, :hash_password}
pipeline &RegisterUser.hash_password/3
If a command is fully run without calling halt/1
, it will have success: true
marked on the struct. Usage might look like this:
%{email: "example@example.com", password: "asdf1234"}
|> RegisterUser.new()
|> RegisterUser.run()
|> case do
%{success: true, data: %{user: user}} ->
# Success! We've got a user now
%{success: false, error: %{password: :not_given}} ->
# Respond with a 400 or something
%{success: false, error: _error} ->
# I'm a lazy programmer that writes catch-all error handling
end
Future Plans
There’s many different directions this project can take, but two that I have in mind: automatic validations/casting and saga rollbacks.
Because of the way attributes are defined, adding types would be easy:
command do
param :email, :string
data :user, User
end
This would allow intelligent casting of Phoenix params, as well as errors if put_data/3
did something you did not expect.
Sagas might be a bit more difficult, and while I might not strive for something as complex and powerful as Sage, rollbacks could be as easy as:
pipeline :create_user, rollback: :delete_user
Feedback
What kind of API would you like to see? Is the command macro straightforward? I’m open to ideas. I’ve begun converting many of my old custom command implementations to commandex, and it works really well for my use cases.