"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
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
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!
✅ Fix: Events should be data-only.
Events::OrderPlaced.new(
order_id: "order_123",
amount: 100_00,
currency: "USD"
)
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
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])
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
Test Projections with Event Replays
events = [Events::OrderPlaced.new(total: 100), Events::OrderPaid.new]
projection = OrderProjection.new(events)
expect(projection.status).to eq(:paid)
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
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
-
Start with one event stream (e.g.,
Orders
). -
Extract one domain to Hexagonal (e.g.,
Core::Billing
). - Add projections for new features only.
- Iterate: Expand as pain points emerge.
"But This Sounds Like Overkill!"
It can be—if you force it everywhere. Start small:
- Add event publishing to one high-value workflow (e.g., payments).
- Keep using ActiveRecord for queries.
- Gradually shift logic to projections.
Have you tried this combo? Share your wins (or battle scars) below.
Top comments (5)
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?
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?
love how you call out the headaches early, real talk. you reckon most folks quit on this stuff too soon, or not soon enough?
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?
This is the kind of Rails content that deserves a second read..