What's New in Rails Event Store 3.0

… and check why 5600+ Rails engineers read also this

What’s New in Rails Event Store 3.0

Rails Event Store 3.0 is primarily a cleanup release.

Throughout the 2.x series we introduced new features while gradually deprecating APIs we no longer wanted to maintain. In 3.0, those deprecated APIs are finally gone.

There are no major new concepts or APIs to learn. The public API is simply smaller, more consistent, and comes with a few stricter defaults.

If you’ve already addressed all deprecation warnings in 2.19, upgrading to 3.0 should be straightforward. This post summarizes what changed, while the 2.19 release announcement explains the motivation behind each deprecation in more detail.


Lean API: the deprecations are gone

The 2.x series was conservative — we kept old method names alive and just warned you about them. 3.0 removes the training wheels: every name kept around for compatibility is gone. Here’s the at-a-glance list of what’s removed and what replaces it (each row links to the reasoning in the 2.19 post):

Removed Use instead
read.in_batches_of(100) read.in_batches(100) details
read.of_types([Type]) read.of_type(Type) details
RubyEventStore::ImmediateAsyncDispatcher RubyEventStore::ImmediateDispatcher details
RailsEventStore::AfterCommitAsyncDispatcher RailsEventStore::AfterCommitDispatcher details
RubyEventStore::Dispatcher RubyEventStore::SyncScheduler details
subscribe(Handler, to: [Type]) subscribe(Handler.new, to: [Type]) details
Mappers::NullMapper.new Mappers::Default.new details
def apply_order_placed(event) on(OrderPlaced) { ... } details
AggregateRoot::Configuration AggregateRoot::Repository.new(event_store) details
read.repository.rails_event_store
call.dispatcher.rails_event_store
read.repository.ruby_event_store
call.dispatcher.ruby_event_store
details
Projection
.from_stream("Orders")
.when(OrderPlaced, handler)
.run(event_store)
Projection
.init({ count: 0 })
.on(OrderPlaced, &handler)
.call(event_store.read.stream("Orders"))
details
RailsEventStore::Event
RailsEventStore::Projection
RailsEventStore::InMemoryRepository
RubyEventStore::Event
RubyEventStore::Projection
RubyEventStore::InMemoryRepository
details

Note — removing the aliases doesn’t touch the genuinely Rails-specific classes. RailsEventStore::Client and RailsEventStore::AfterCommitDispatcher aren’t re-exports of anything — they exist only because of Rails (ActiveRecord, transaction callbacks), so they stay. Only the constants that merely pointed at a RubyEventStore:: original are gone.

The one replacement that isn’t a rename is EventClassRemapper — its successor, upcasting, needs a real handler.

EventClassRemapper is gone — use upcasting

In event sourcing, events are immutable facts — once written, a record’s event_type stays as it was, a plain string you never go back and rewrite. By convention that string is the class name, and that’s exactly what a rename breaks: move OrderPlaced into an Ordering module and the events already stored as "OrderPlaced" no longer resolve to Ordering::OrderPlaced on read.

In 2.x you patched this on read with the events_class_remapping: option — a string-to-string lookup:

# 2.x — removed
RubyEventStore::Mappers::Default.new(
  events_class_remapping: { "OrderPlaced" => "Ordering::OrderPlaced" }
)

3.0 replaces it with the upcasting transformation. A Record is immutable, so the upcast lambda receives the old record and returns a brand-new one — for a rename you change only event_type:

# 3.0
upcast = RubyEventStore::Mappers::Transformation::Upcast.new(
  "OrderPlaced" => ->(record) do
    RubyEventStore::Record.new(
      event_type: "Ordering::OrderPlaced",
      data:       record.data,
      event_id:   record.event_id,
      metadata:   record.metadata,
      timestamp:  record.timestamp,
      valid_at:   record.valid_at,
    )
  end
)

mapper = RubyEventStore::Mappers::Pipeline.new(
  upcast,
  RubyEventStore::Mappers::Transformation::SymbolizeMetadataKeys.new,
)

RailsEventStore::Client.new(mapper: mapper)

Starting from a full Record is the whole point: it lets you do far more than rename the type. You can reshape data between versions, split or merge fields, backfill a value that older events never carried — and chain entries so each record is upgraded step by step until it stops changing. That flexibility is what the few extra lines buy you over a one-line hash.

Other removals

A handful of smaller cleanups round out the release, grouped by where they’d reach you — chances are most won’t.

If you subscribe to instrumentation

  • serialize / deserialize mapper events removed.
    They were renamed to event_to_record / record_to_event (payload key domain_event:event:); update any ActiveSupport::Notifications subscriptions on the old names.
  • events: / messages: payload keys removed.
    The append_to_stream and update_messages notifications now carry only records: — read that key instead.

If you customize mappers or aggregates

  • JSONMapper removed.
    It was a thin Default subclass — Default already handles JSON, so use that instead.
  • with_default_apply_strategy / with_strategy removed.
    The default strategy already comes with include AggregateRoot; for a custom one, use AggregateRoot.with(strategy: …).

Stricter by default

  • nil to publish / append / link is now rejected.
    It used to warn and carry on — now it raises ArgumentError, so guard calls that might pass an empty result.
  • ensure_supported_any_usage removed.
    InMemoryRepository now always rejects mixing expected_version: :any with specific positions — matching the SQL repositories, so in-memory tests catch what production would.

And one warning simply went away — the spurious rails_event_store_active_record rename warning is gone (backstory in the 2.19 post).


Upgrade guide

Start on 2.19 and clear every deprecation warning first — once your test suite is quiet, the table above is your checklist and the rest of the upgrade is mostly find-and-replace. Three changes need a real edit rather than a rename.

Projection API — define the projection once, then call it with any scope:

# before
Projection
  .from_stream("Orders")
  .when(OrderPlaced, handler)
  .run(event_store)

# after
Projection
  .init({ count: 0 })
  .on(OrderPlaced, &handler)
  .call(event_store.read.stream("Orders"))

AggregateRoot default_event_store — the global default is gone; wire the event store explicitly through AggregateRoot::Repository.new(event_store). Details in the 2.19 post.

EventClassRemapper — replace the events_class_remapping: hash with an upcasting transformation on your mapper, as shown in the upcasting section above.

You might also like