Building Complex Workflows with Composition
View SourceIn this tutorial, you'll learn how to build large, maintainable workflows by composing smaller reactors together. This is essential for managing complexity in real-world applications.
What you'll build
A multi-stage e-commerce order processing system that:
- User Management - Validates and enriches user data
- Inventory Management - Checks and reserves product inventory
- Payment Processing - Handles payment authorization and capture
- Order Fulfillment - Coordinates shipping and notifications
- Master Orchestrator - Composes all sub-workflows together
You'll learn
- How to break complex workflows into composable reactors
- When to use composition vs building one large reactor
- How to pass data between composed reactors
- Error handling and rollback across composed workflows
- Testing strategies for composed systems
Prerequisites
- Complete the Getting Started tutorial
- Complete the Error Handling tutorial
- Complete the Async Workflows tutorial
Step 1: Set up the project
If you don't have a project from the previous tutorials:
mix igniter.new reactor_tutorial --install reactor
cd reactor_tutorial
Step 2: Understanding Reactor composition
Reactor composition allows you to:
Break down complexity - Instead of one massive reactor, create focused sub-reactors:
# Use composition:
defmodule OrderProcessor do
use Reactor
compose :user_management, UserManagementReactor
compose :inventory_check, InventoryReactor
compose :payment_processing, PaymentReactor
compose :fulfillment, FulfillmentReactor
end
Enable reusability - Sub-reactors can be used in multiple contexts:
compose :validate_buyer, UserManagementReactor
compose :validate_seller, UserManagementReactor
Improve testability - Test each sub-reactor independently.
Step 3: Create simple domain reactors
Let's start by building focused reactors for each domain. Create lib/user_validation_reactor.ex
:
defmodule UserValidationReactor do
use Reactor
input :user_id
step :fetch_user do
argument :user_id, input(:user_id)
run fn %{user_id: user_id}, _context ->
{:ok, %{
id: user_id,
name: "User #{user_id}",
email: "user#{user_id}@example.com",
active: true
}}
end
end
step :validate_user do
argument :user, result(:fetch_user)
run fn %{user: user}, _context ->
if user.active do
{:ok, user}
else
{:error, "User is not active"}
end
end
end
return :validate_user
end
Create lib/inventory_reactor.ex
:
defmodule InventoryReactor do
use Reactor
input :product_id
input :quantity
step :check_availability do
argument :product_id, input(:product_id)
argument :quantity, input(:quantity)
run fn %{product_id: product_id, quantity: quantity}, _context ->
available = 50
if quantity <= available do
{:ok, %{product_id: product_id, available: available}}
else
{:error, "Not enough inventory"}
end
end
end
step :reserve_items do
argument :availability, result(:check_availability)
argument :quantity, input(:quantity)
run fn %{availability: avail, quantity: qty}, _context ->
reservation = %{
product_id: avail.product_id,
quantity: qty,
reserved_at: DateTime.utc_now()
}
{:ok, reservation}
end
end
return :reserve_items
end
Create lib/payment_reactor.ex
:
defmodule PaymentReactor do
use Reactor
input :user_id
input :amount
step :validate_payment do
argument :user_id, input(:user_id)
argument :amount, input(:amount)
run fn %{user_id: user_id, amount: amount}, _context ->
if amount > 0 and amount < 10000 do
{:ok, %{user_id: user_id, amount: amount, valid: true}}
else
{:error, "Invalid payment amount"}
end
end
end
step :process_payment do
argument :payment_info, result(:validate_payment)
run fn %{payment_info: info}, _context ->
payment = %{
payment_id: "pay_#{:rand.uniform(1000)}",
user_id: info.user_id,
amount: info.amount,
status: :completed,
processed_at: DateTime.utc_now()
}
{:ok, payment}
end
end
return :process_payment
end
Step 4: Create the master orchestrator
Now create the main reactor that composes all sub-reactors. Create lib/order_processing_reactor.ex
:
defmodule OrderProcessingReactor do
use Reactor
input :user_id
input :product_id
input :quantity
input :amount
# Step 1: Validate user
compose :user_validation, UserValidationReactor do
argument :user_id, input(:user_id)
end
# Step 2: Check inventory (can run in parallel with payment)
compose :inventory_check, InventoryReactor do
argument :product_id, input(:product_id)
argument :quantity, input(:quantity)
end
# Step 3: Process payment
compose :payment_processing, PaymentReactor do
argument :user_id, input(:user_id)
argument :amount, input(:amount)
end
# Step 4: Create final order (depends on all previous steps)
step :create_order do
argument :user, result(:user_validation)
argument :reservation, result(:inventory_check)
argument :payment, result(:payment_processing)
run fn %{user: user, reservation: res, payment: pay}, _context ->
order = %{
order_id: "order_#{:rand.uniform(1000)}",
user: user,
product_id: res.product_id,
quantity: res.quantity,
payment_id: pay.payment_id,
total: pay.amount,
created_at: DateTime.utc_now()
}
{:ok, order}
end
end
return :create_order
end
Step 5: Test the composition
Let's test our composed reactor:
iex -S mix
# Test the composed order processing
{:ok, order} = Reactor.run(OrderProcessingReactor, %{
user_id: 123,
product_id: 456,
quantity: 2,
amount: 99.99
})
IO.inspect(order.order_id)
# Should output something like "order_123"
# Test individual reactors too
{:ok, user} = Reactor.run(UserValidationReactor, %{user_id: 123})
IO.inspect(user.name)
# Should output "User 123"
What you learned
You now understand Reactor composition:
- Composition over monoliths - Many small reactors are easier to manage than one large one
- Clear interfaces - Define clear inputs and outputs for each sub-reactor
- Independent testing - Test each reactor in isolation before testing compositions
- Error boundaries - Failures in sub-reactors can be handled by the parent reactor
- Reusability - Well-designed sub-reactors can be used in multiple contexts
Design guidelines:
- Single responsibility - Each reactor should have one clear purpose
- Loose coupling - Minimise dependencies between reactors
- High cohesion - Related steps belong in the same reactor
What's next
Now that you understand composition, you're ready for advanced patterns:
- Recursive Execution - Handle iterative and recursive workflows
- Testing Strategies - Comprehensive testing approaches
- Performance Optimization - Advanced performance techniques
Common issues
Sub-reactor outputs don't match expectations: Use clear interface contracts and validate inputs/outputs in tests
Error handling doesn't work across boundaries: Ensure compensation and undo are implemented in the appropriate reactor layer
Composed reactors are hard to debug: Test each sub-reactor individually and use descriptive logging
Happy building modular workflows! 🧩