DEV Community

Alex Aslam
Alex Aslam

Posted on

Event Sourcing + Hexagonal Rails: A Survival Guide

"We built the perfect event-sourced system—until we needed to change it."

Event sourcing gives you an immutable audit log of everything that’s happened in your system. Hexagonal architecture keeps your business logic framework-independent. Combine them, and you get a system that’s:
Debuggable (replay past states)
Decoupled (swap storage, UIs, or frameworks)
Maintainable (isolate changes)

But when done wrong? You’ll drown in event spaghetti, leaky abstractions, and replay hell.

Here’s how to make them work together—without overengineering.


1. Where Hexagonal Meets Event Sourcing

Traditional Rails

# Tightly coupled, CRUD-style
class Order < ApplicationRecord
  after_save :send_confirmation_email
end
Enter fullscreen mode Exit fullscreen mode

Hexagonal + Event-Sourced Rails

# Core domain (pure Ruby)
class Order
  def place(cart_id)
    Events::OrderPlaced.new(cart_id: cart_id)
  end
end

# Adapter (Rails-specific)
class OrdersController < ApplicationController
  def create
    command = Core::PlaceOrder.new
    event = command.call(params[:cart_id])
    EventStore.publish(event) # Persist event
  end
end

# Projection (Rebuilds state)
class OrderProjection
  def initialize(events)
    @state = events.reduce({}) { |state, event| apply(event, state) }
  end

  def apply(event, state)
    case event
    when Events::OrderPlaced then { status: :placed }
    when Events::OrderPaid   then { status: :paid }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Key Wins:

  • Business rules stay in Core:: (no ActiveRecord callbacks).
  • Events define what happened, not how to process it.
  • Projections rebuild state however you need (Rails models, APIs, etc.).

2. The Integration Traps (And Fixes)

Trap 1: Leaky Event Schemas

Bad: Events depend on ActiveRecord models.

event = Events::OrderPlaced.new(order: Order.last) # Tight coupling!
Enter fullscreen mode Exit fullscreen mode

Fix: Events should be data-only.

Events::OrderPlaced.new(
  order_id: "order_123",
  amount: 100_00,
  currency: "USD"
)
Enter fullscreen mode Exit fullscreen mode

Trap 2: Projection Drift

Bad: Changing an event breaks old projections.
Fix: Use upcasters to evolve schemas:

class Events::OrderPlaced
  def upcast(raw_event)
    # Backfill new fields safely
    raw_event.data.merge(currency: raw_event.data.fetch(:currency, "USD"))
  end
end
Enter fullscreen mode Exit fullscreen mode

Trap 3: Overloaded Event Handlers

Bad: One handler does email, analytics, and inventory.
Fix: Single-responsibility subscribers:

EventStore.subscribe(SendOrderConfirmation, to: [Events::OrderPlaced])
EventStore.subscribe(TrackOrderAnalytics, to: [Events::OrderPlaced])
Enter fullscreen mode Exit fullscreen mode

3. Testing Without the Pain

Test Commands in Isolation

it "rejects negative totals" do
  command = Core::PlaceOrder.new
  expect { command.call(total: -100) }.to raise_error(InvalidOrder)
end
Enter fullscreen mode Exit fullscreen mode

Test Projections with Event Replays

events = [Events::OrderPlaced.new(total: 100), Events::OrderPaid.new]
projection = OrderProjection.new(events)
expect(projection.status).to eq(:paid)
Enter fullscreen mode Exit fullscreen mode

Test Adapters with Mocks

let(:mock_event_store) { double(publish: true) }

it "publishes on success" do
  controller = OrdersController.new(event_store: mock_event_store)
  expect(mock_event_store).to receive(:publish)
  controller.create
end
Enter fullscreen mode Exit fullscreen mode

4. When to Avoid This Combo

🚫 Simple CRUD apps (Overkill for basic forms)
🚫 No DevOps support (Event stores need monitoring)
🚫 Tight deadlines (Slows initial development)

Rule of Thumb:

Use this when debuggability and long-term flexibility matter more than speed.


5. Gradual Adoption Path

  1. Start with one event stream (e.g., Orders).
  2. Extract one domain to Hexagonal (e.g., Core::Billing).
  3. Add projections for new features only.
  4. Iterate: Expand as pain points emerge.

"But This Sounds Like Overkill!"

It can be—if you force it everywhere. Start small:

  1. Add event publishing to one high-value workflow (e.g., payments).
  2. Keep using ActiveRecord for queries.
  3. Gradually shift logic to projections.

Have you tried this combo? Share your wins (or battle scars) below.

Top comments (5)

Collapse
 
dotallio profile image
Dotallio

Really appreciate how you broke down the integration traps - I’ve definitely run into event drift before. Has anyone found a sweet spot for how gradually you introduce projections without stalling progress?

Collapse
 
alex_aslam profile image
Alex Aslam

Great question. The sweet spot we’ve found: introduce projections only when you need them for new features or critical debugging. Start by publishing events from your existing code, then build projections incrementally as requirements demand, not all at once.
For legacy systems, wrap the current state in an adapter so new projections can coexist with old logic. This keeps momentum while avoiding big-bang rewrites.
How are you balancing progress with projection consistency in your projects?

Collapse
 
nevodavid profile image
Nevo David

love how you call out the headaches early, real talk. you reckon most folks quit on this stuff too soon, or not soon enough?

Collapse
 
alex_aslam profile image
Alex Aslam

Honestly, both happen. Some teams bail too soon when they hit the first complexity wall, missing the long-term payoff. Others overcommit and drown in abstraction before solving real problems.
The key is picking one high-value area (like payments) to prove the pattern, then expand deliberately. No dogma, just pragmatism.
Where have you seen teams swing too far in either direction?

Collapse
 
parag_nandy_roy profile image
Parag Nandy Roy

This is the kind of Rails content that deserves a second read..