DEV Community

Alex Aslam
Alex Aslam

Posted on

From Legacy to Event-Sourced: A Step-by-Step Migration Guide

"Your CRUD app is a ticking time bomb. Here’s how to defuse it—without a rewrite."

Legacy systems are like old houses:

  • Every change risks breaking something
  • No one remembers why the plumbing works this way
  • Adding a new room feels impossible

But migrating to event sourcing doesn’t require burning it all down.

Here’s how we moved a 500-table Rails monolith to event sourcing—one component at a time—without downtime.


Phase 1: Lay the Foundation

Step 1: Identify the Pain Points

Start with the highest-value, highest-pain areas:

  • Audit-heavy domains (payments, user access)
  • Complex workflows (order fulfillment)
  • Frequent "how did this happen?" debugging

Avoid: Static reference data (e.g., product catalogs).

Step 2: Add Event Publishing to Legacy Code

# Wrap existing updates in events
class Order < ApplicationRecord
  after_update :publish_events

  def publish_events
    if status_previously_changed?
      EventPublisher.publish(
        OrderStatusChanged.new(
          order_id: id,
          old_status: status_previous_change[0],
          new_status: status
        )
      )
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Why? Starts building an event history without changing behavior.


Phase 2: Dual-Write Architecture

Step 3: Build Parallel Projections

class OrderProjection
  def self.for(order_id)
    events = EventStore.for(order_id)
    events.reduce({}) { |state, event| apply(state, event) }
  end

  def self.apply(state, event)
    case event
    when OrderStatusChanged
      state.merge(status: event.new_status)
    # ...
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Test by comparing:

LegacyOrder.find(123).status == OrderProjection.for(123).status
Enter fullscreen mode Exit fullscreen mode

Step 4: Shift Reads Gradually

  1. New features use projections
  2. Legacy code keeps using ActiveRecord
  3. Monitor for mismatches

Phase 3: The Big Flip

Step 5: Replace Writes with Commands

# Before
order.update!(status: "shipped")

# After
ShipOrderCommand.call(order_id: order.id)
# Emits OrderShipped event
Enter fullscreen mode Exit fullscreen mode

Step 6: Retire Legacy Code

  1. Verify projections match 100% for 30 days
  2. Redirect all reads to projections
  3. Drop unused tables (celebrate 🎉)

Migration Pitfalls

🚨 Pitfall 1: Event schema changes

  • Fix: Use upcasters to version events

🚨 Pitfall 2: Projection drift

  • Fix: Rebuild from events + checksums

🚨 Pitfall 3: Performance regressions

  • Fix: Add snapshots for hot aggregates

When to Abort

Team lacks event-sourcing experience (train first!)
No buy-in for incremental migration
Leadership expects "2-week rewrite"


"But We Have 500 Models!"

So did we. Start with one:

  1. Pick a bounded context (e.g., payments)
  2. Prove value (better audits? faster debugging?)
  3. Expand iteratively

Have you survived a migration? Share your lessons below.

Top comments (0)