Recipe
Intro
The Recipe
module allows implementing multi-step, reversible workflows.
For example, you may wanna parse some incoming data, write to two different
data stores and then push some notifications. If anything fails, you wanna
rollback specific changes in different data stores. Recipe
allows you to do
that.
In addition, a recipe doesn’t enforce any constraint around which processes execute which step. You can assume that unless you explicitly involve other processes, all code that builds a recipe is executed by default by the calling process.
Ideal use cases are:
- multi-step operations where you need basic transactional properties, e.g. saving data to Postgresql and Redis, rolling back the change in Postgresql if the Redis write fails
- interaction with services that simply don’t support transactions
- composing multiple workflows that can share steps (with the
help of
Kernel.defdelegate/2
) - trace workflows execution via a correlation id
You can avoid using this library if:
- A simple
with
macro will do - You don’t care about failure semantics and just want your operation to crash the calling process
- Using Ecto, you can express your workflow with
Ecto.Multi
Heavily inspired by the ktn_recipe
module included in inaka/erlang-katana.
Core ideas
- A workflow is a state machine
- Each step can have a specific error handling scenario
- Each step is a separate function that receives a state with the result of all previous steps
- Each step should be easily testable in isolation
Installation
If available in Hex, the package can be installed
by adding recipe
to your list of dependencies in mix.exs
:
def deps do
[{:recipe, "~> 0.2.0"}]
end
Example
The example below outlines a possible workflow where a user creates a new conversation, passing an initial message.
Each step is named in steps/0
. Each step definition uses data added to the
workflow state and performs a specific task.
Any error shortcuts the workflow to handle_error/3
, where a specialized
clause for :create_initial_message
deletes the conversation if the system
failes to create the initial message (therefore simulating a transaction).
defmodule StartNewConversation do
use Recipe
### Public API
def run(user_id, initial_message_text) do
state = Recipe.empty_state
|> Recipe.assign(:user_id, user_id)
|> Recipe.assign(:initial_message_text, initial_message_text)
Recipe.run(__MODULE__, state)
end
### Callbacks
def steps, do: [:validate,
:create_conversation,
:create_initial_message,
:broadcast_new_conversation,
:broadcast_new_message]
def handle_result(state) do
state.assigns.conversation
end
def handle_error(:create_initial_message, _error, state) do
Service.Conversation.delete(state.conversation.id)
end
def handle_error(_step, error, _state), do: error
### Steps
def validate(state) do
text = state.assigns.initial_message_text
if MessageValidator.valid_text?(text) do
{:ok, state}
else
{:error, :empty_message_text}
end
end
def create_conversation(state) do
case Service.Conversation.create(state.assigns.user_id) do
{:ok, conversation} ->
{:ok, Recipe.assign(state, :conversation, conversation)}
error ->
error
end
end
def create_initial_message(state) do
%{user_id: user_id,
conversation: conversation,
initial_message_text: text} = state.assigns
case Service.Message.create(user_id, conversation.id, text) do
{:ok, message} ->
{:ok, Recipe.assign(state, :initial_message, message)}
error ->
error
end
end
def broadcast_new_conversation(state) do
Dispatcher.broadcast("conversation-created", state.assigns.conversation)
{:ok, state}
end
def broadcast_new_message(state) do
Dispatcher.broadcast("message-created", state.assigns.initial_message)
{:ok, state}
end
end
Development/Test
- Initial setup can be done with
mix deps.get
- Run tests with
mix test
- Run dialyzer with
mix dialyzer
- Run credo with
mix credo
- Build docs with
MIX_ENV=docs mix docs