Building Complex Workflows with Composition

October 14, 2025 · View on GitHub

In 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:

  1. User Management - Validates and enriches user data
  2. Inventory Management - Checks and reserves product inventory
  3. Payment Processing - Handles payment authorization and capture
  4. Order Fulfillment - Coordinates shipping and notifications
  5. 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

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:

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! 🧩