hpopp
Commandex - Make complex actions a first class data type
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.
https://github.com/codedge-llc/commandex
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.
Most Liked
dimitarvp
Few notes / questions, mostly around naming or convenience:
- If I end up using such a library I’d appreciate it if I had a convenience function that does both
new()andrun(). Something likecreate_and_run(), maybe? Ormake_and_run()? Or simplystart? - Not sure how intuitive the naming of
paramsanddatais. I’d be a bit more formal and christen theminputandoutput. - Ditto for
pipeline. I get it that you probably wantPlugusers to feel at home but IMO something likeactionis clearer. - In my eyes
{:ok, %{user: user, password_hash: "123"}}and{:error, error_object_or_message}are more idiomatic Elixir. (Note thatEcto.Multipartially follows your pattern but still uses tuples.)
To recap, this is how I’d find the whole thing more readable:
defmodule RegisterUser do
import Commandex
command do
input :email
input :password
output :password_hash
output :user
action :hash_password
action :create_user
action :send_welcome_email
end
...action functions go here
end
Don’t take my criticism seriously if you like your naming. I am only giving you a pure anecdote on what I’d find more intuitive / readable.
Lastly, as a friendly competition, you can take a look at Opus (they seem to like your pipeline naming
).
gregvaughn
The very fact that there are different, well-reasoned, opinions on which direction to take things has kept me skeptical if building a library is worthwhile, vs. educational materials on the pattern itself.
gregvaughn
There’s value in a library, but there’s always tradeoffs involved. I’m probably not thinking creatively enough, but I’m not seeing a lot of value for protocols for this, so the value in keeping that an option is lost on me.
I’ve been building out a custom command pattern use in my primary work system. I have it set up to return {:ok, command_struct} or {:error, exception_struct} and I’m liking the affordances that offers.
Perhaps there’s some middle ground to be had if the library offered both bang and non-bang new and run functions. Protocols only work with the bang functions, but for something more bespoke, you can give that up and use the non-bang versions. OTOH, that might tip the library into the realm of too complex ¯_(ツ)_/¯
Popular in Announcing
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance








