<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://blog.arkency.com/">
  <id>https://blog.arkency.com/</id>
  <title>Hi, we're Arkency</title>
  <updated>2026-06-16T13:21:30Z</updated>
  <link rel="alternate" href="https://blog.arkency.com/" type="text/html"/>
  <link rel="self" href="https://blog.arkency.com/atom.xml" type="application/atom+xml"/>
  <author>
    <name>Arkency</name>
    <uri>https://arkency.com</uri>
  </author>
  <entry>
    <id>tag:blog.arkency.com,2026-06-16:/ingress-is-not-the-owner-of-the-invariant/</id>
    <title type="html">Ingress is not the owner of the invariant</title>
    <published>2026-06-16T13:21:30Z</published>
    <updated>2026-06-16T13:21:30Z</updated>
    <author>
      <name>Szymon Fiedler</name>
      <uri>https://blog.arkency.com/authors/szymon-fiedler/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/ingress-is-not-the-owner-of-the-invariant/" type="text/html"/>
    <content type="html">&lt;h1 id="ingress_is_not_the_owner_of_the_invariant"&gt;Ingress is not the owner of the invariant&lt;/h1&gt;
&lt;p&gt;A polemic with &lt;a href="https://baweaver.com/writing/2026/06/13/rails-sharp-parts-callbacks-are-not-invariants/"&gt;Callbacks Are Not Invariants&lt;/a&gt; by Brandon Weaver.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A disclaimer: I’m a &lt;a href="https://railseventstore.org"&gt;RailsEventStore&lt;/a&gt; maintainer and this article ends up on the Arkency blog — so cards are on the table. Despite this, I’m keeping the core of my argument in pure &lt;code&gt;ActiveRecord&lt;/code&gt;: no step of the reasoning requires &lt;em&gt;RES&lt;/em&gt;. I only show the &lt;em&gt;RES&lt;/em&gt; version at the end, separately, as &amp;ldquo;and this is what it looks like when you’re not typing it in manually&amp;rdquo;. If you’re convinced by the bare-metal &lt;em&gt;AR&lt;/em&gt; reasoning, not the library, that’s what matters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;!-- more --&gt;
&lt;h2 id="we_agree_about_the_disease"&gt;We agree about the disease&lt;/h2&gt;
&lt;p&gt;I enjoy reading Brandon’s &lt;em&gt;Rails: The Sharp Parts&lt;/em&gt; series and sending it to the team — it’s one of the better pieces on the sharp edges of Rails that’s come out lately. The one about callbacks is no exception and the diagnosis is spot on. Census &lt;code&gt;_save_callbacks&lt;/code&gt;, which shows eleven entries with two association lines and zero callbacks of its own. A mismatch between what fires before and after &lt;code&gt;COMMIT&lt;/code&gt;. The best sentence in the entire text: &amp;ldquo;a callback is an invariant with a published bypass list&amp;rdquo; — &lt;code&gt;update_all&lt;/code&gt;, &lt;code&gt;insert_all&lt;/code&gt;, &lt;code&gt;update_column&lt;/code&gt; are holes in something that was supposed to &lt;em&gt;always happen&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I’m not here to defend the callbacks. I hate them myself, for the same reasons. The dispute is about the cure.&lt;/p&gt;

&lt;p&gt;And I have one reservation for the cure Brandon proposes — and it doesn’t concern what he built, but how he named it. Because the name will travel beyond a single file, to anyone who copies the template.&lt;/p&gt;
&lt;h2 id="a_name_that_promises_more_than_it_delivers"&gt;A name that promises more than it delivers&lt;/h2&gt;
&lt;p&gt;What the author calls &lt;code&gt;Command&lt;/code&gt; is Fowler’s &lt;code&gt;Transaction Script&lt;/code&gt;. One public call, a private &lt;code&gt;execute&lt;/code&gt;, a procedure orchestrating &lt;code&gt;ActiveRecord&lt;/code&gt; calls. And that’s a good pattern — it’s forty years old and still going strong. The thing is, he calls it a command (suggesting CQRS, which itself announces &lt;em&gt;next time&lt;/em&gt;) and calls &lt;code&gt;ActiveSupport::Notifications&lt;/code&gt; events.&lt;/p&gt;

&lt;p&gt;A name isn’t cosmetic. A name is a mental contract. When 500 engineers work in a monolith — and that’s the scale Weaver explicitly writes for — names are the only documentation anyone reads. If you tell them they’re building &lt;em&gt;commands&lt;/em&gt; and &lt;em&gt;events&lt;/em&gt;, in a year, half the company will think they’re building an event-driven architecture with procedures in their hands. That’s worse than no name, because it installs a mental model that doesn’t match the code.&lt;/p&gt;

&lt;p&gt;And this isn’t about arguing with its values but about appealing to them. Brandon writes about himself that his goal is to make the invisible visible — and that the next person reading the code shouldn’t have to wonder what the author meant. I agree with that with both hands. And that’s precisely why calling a &lt;em&gt;procedure&lt;/em&gt; a &lt;em&gt;command&lt;/em&gt; and a &lt;em&gt;notification&lt;/em&gt; an &lt;em&gt;event&lt;/em&gt; plays against what he wants: it forces the reader to assume a contract — a serializable intent with a separate handler, a persistent domain fact — that doesn’t exist in the code. Correcting the names isn’t a quarrel with Weaver; it’s the completion of his own goal.&lt;/p&gt;
&lt;h2 id="core__ingress_doesn_t_own_the_invariant"&gt;Core: ingress doesn’t own the invariant&lt;/h2&gt;
&lt;p&gt;Single-ingress is correct — one write path per operation, one entry point that owns the state change. But look where the &amp;ldquo;space cannot be reserved twice&amp;rdquo; invariant lives after the refactor:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reserve&lt;/span&gt;
  &lt;span class="n"&gt;seat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seat_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_lock&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;AlreadyReserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"seat &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;seat_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is already reserved"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserved?&lt;/span&gt;
    &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;reserved: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;reserved_by: &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;Ledger&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RecordReservation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;seat: &lt;/span&gt;&lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;by: &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="n"&gt;seat&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The rule is spread across three layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a check in runtime (&lt;code&gt;if seat.reserved?&lt;/code&gt;) &lt;/li&gt;
&lt;li&gt;a row lock (&lt;code&gt;with_lock&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;a constraint in the database — as Brandon rightly writes elsewhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn&amp;rsquo;t a domain model. It&amp;rsquo;s a &lt;code&gt;Seat.find&lt;/code&gt; + &lt;code&gt;update!&lt;/code&gt; wrapped in a procedure. Infrastructure-first, just repurposed from a callback to a service object. The question &amp;ldquo;who owns the reservation rule&amp;rdquo; still doesn&amp;rsquo;t have a single answer.&lt;/p&gt;

&lt;p&gt;The coupling is now deliberate and visible — but it&amp;rsquo;s still there. One trade-off and one naming issue:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Side effects inline in &lt;code&gt;execute&lt;/code&gt;&lt;/strong&gt;. Weaver is explicit about this: &lt;code&gt;announce&lt;/code&gt; belongs to the command body by design, not by accident — subscribers are reserved for observability only and cannot veto a write or introduce ordering dependencies. That&amp;rsquo;s a defensible trade-off: visible coupling beats hidden coupling every time. My claim is narrower: &lt;code&gt;announce&lt;/code&gt; fires after &lt;code&gt;with_lock&lt;/code&gt; commits, so the timing is fine — but if the process dies between commit and &lt;code&gt;deliver_later&lt;/code&gt;, the effect is gone forever. There is nothing to replay from, because the fact was never persisted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;event_name&lt;/code&gt; from the namespace system&lt;/strong&gt;. The event name extracted from &lt;code&gt;module_parent_name&lt;/code&gt; ties the &lt;em&gt;event&lt;/em&gt; taxonomy — that is, &lt;em&gt;the contract&lt;/em&gt; — to the directory structure in the code. Move a module, and the names of events that someone might already be subscribed to change. This is exactly the kind of invisible coupling he&amp;rsquo;s been fighting against throughout this article — only this time it moves a layer higher.&lt;/p&gt;
&lt;h2 id="what_it_looks_like_when_an_invariant_has_an_owner"&gt;What it looks like when an invariant has an owner&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;ll show the difference in code, because otherwise, it&amp;rsquo;s just adjectives. Pure &lt;code&gt;ActiveRecord&lt;/code&gt;, without &lt;code&gt;RailsEventStore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;First, the aggregate. It — and only it — decides whether the reservation is allowed and produces the fact. No &lt;em&gt;IO&lt;/em&gt;, no &lt;em&gt;mailer&lt;/em&gt;, no &lt;em&gt;webhook&lt;/em&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Seat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AlreadyReserved&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="c1"&gt;# Invariant lives here. In one place. The method always returns an event,&lt;/span&gt;
  &lt;span class="c1"&gt;# and doesn't fire side effects.&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;AlreadyReserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"seat &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is already reserved"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reserved?&lt;/span&gt;

    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserved&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserved_by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserved_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;

    &lt;span class="no"&gt;SeatReserved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;seat_id: &lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;reserved_by: &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;reserved_at: &lt;/span&gt;&lt;span class="n"&gt;reserved_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;An &lt;em&gt;event&lt;/em&gt; is a fact. Past tense. Payload is a result, not a request:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;SeatReserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:seat_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:reserved_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:reserved_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"seat_reserved"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;em&gt;handler&lt;/em&gt; is a simple &lt;em&gt;PORO&lt;/em&gt; — one application use-case, one entrance for write. We&amp;rsquo;re keeping Brandon&amp;rsquo;s single-entry discipline because it&amp;rsquo;s good; we&amp;rsquo;re not inheriting his &lt;code&gt;ApplicationCommand&lt;/code&gt; base — its only job was &lt;code&gt;announce&lt;/code&gt;, and that role is now taken by the explicit &lt;code&gt;SeatReserved&lt;/code&gt; with persistent log write and subscribers. There&amp;rsquo;s no constructor, no ivars, and no &lt;code&gt;self.call&lt;/code&gt; to &lt;code&gt;new.call&lt;/code&gt; relay — the handler is so thin that there&amp;rsquo;s nothing to decompose. And that&amp;rsquo;s the point: the rule has been moved to the aggregate, so orchestration remains trivial, and everything happens on the correct side of the commit.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Seats&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReserveSeat&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seat_id&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
      &lt;span class="no"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
        &lt;span class="n"&gt;seat&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seat_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;   &lt;span class="c1"&gt;# aggregate guards the invariant &lt;/span&gt;
        &lt;span class="n"&gt;seat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save!&lt;/span&gt;
        &lt;span class="no"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# fact in the same transaction&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;Events.publish&lt;/code&gt; does two things, both in the caller&amp;rsquo;s transaction: it writes the event to a persistent log and synchronously calls subscribers. First, the log: append-only, the source of truth, from which you replay:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Persistent event log — source of truth. &lt;/span&gt;
&lt;span class="c1"&gt;# As long as the event lives here, it can be replayed.&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StoredEvent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;occurred_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And the &lt;em&gt;dispatcher&lt;/em&gt;. No magic at all: &lt;code&gt;publish&lt;/code&gt; stores the fact, and then calls &lt;em&gt;handlers&lt;/em&gt; subscribed to the &lt;em&gt;event name&lt;/em&gt; — in the same thread, and in the same transaction:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# publish: persistent fact + sync subscribers, atomically with the state change.&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Events&lt;/span&gt;
  &lt;span class="no"&gt;HANDLERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;StoredEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;HANDLERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;HANDLERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here&amp;rsquo;s all the persistence I need: &lt;code&gt;StoredEvent.append&lt;/code&gt; runs in the same transaction as &lt;code&gt;seat.save!&lt;/code&gt; — because &lt;code&gt;publish&lt;/code&gt; doesn&amp;rsquo;t open its own transaction, the INSERT inherits the active one from the caller. The fact commits with the state change or not at all. Since the fact remains, any reaction can be recreated — handler retry, log replay. This is the invariant, and you have it without any additional machinery.&lt;/p&gt;

&lt;p&gt;One fair boundary. The subscribers above execute synchronously, in a transaction — which is exactly what you want for reactions intended to be atomic with the fact. But a subscriber doing heavy or external IO (mail, webhook) shouldn&amp;rsquo;t block the transaction; it schedules the work asynchronously, via &lt;code&gt;deliver_later&lt;/code&gt;. And here comes the only gap Weaver worries about: the very scheduling of this asynchronous work isn&amp;rsquo;t atomic by default with the fact&amp;rsquo;s save. This narrow gap — and nothing else — is patched by the transactional outbox. It doesn&amp;rsquo;t patch the persistence of the fact, because that&amp;rsquo;s already taken care of. &lt;/p&gt;

&lt;p&gt;And the mailer and the webhook? They react to the fact. They&amp;rsquo;re not steps in the write path — they&amp;rsquo;re subscribers. You can add, remove, or replace one without touching the &lt;code&gt;ReserveSeat&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"seat_reserved"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="no"&gt;ReservationMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;confirmed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seat_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_later&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"seat_reserved"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="no"&gt;Webhooks&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Emit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;event: :seat_reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Let&amp;rsquo;s compare both approaches:&lt;/p&gt;

&lt;table&gt;&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Weaver&lt;/th&gt;
&lt;th&gt;Here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Invariant&lt;/td&gt;
&lt;td&gt;runtime check + &lt;code&gt;with_lock&lt;/code&gt; + constraint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Seat#reserve&lt;/code&gt;, single place&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reserve_seat.seats&lt;/code&gt; from namespace, payload = input&lt;/td&gt;
&lt;td&gt;&lt;code&gt;seat_reserved&lt;/code&gt;, explicit, payload = result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Side-effects&lt;/td&gt;
&lt;td&gt;inline in &lt;code&gt;announce&lt;/code&gt; (deliberate — observability subscribers can&amp;rsquo;t veto writes)&lt;/td&gt;
&lt;td&gt;subscribers react to the persisted fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery&lt;/td&gt;
&lt;td&gt;fire-and-forget after commit, can be lost&lt;/td&gt;
&lt;td&gt;event in a transaction, always replayable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;

&lt;p&gt;And here&amp;rsquo;s the point I care about most — because it affects the domain I&amp;rsquo;m currently working on: persisting the fact isn&amp;rsquo;t an add-on — it&amp;rsquo;s a day-one invariant. Weaver himself is clear: &lt;em&gt;&amp;ldquo;If you need durable event delivery (guaranteed at-least-once), that&amp;rsquo;s a transactional outbox or CDC, not a subscriber.&amp;rdquo;&lt;/em&gt; So we agree on the destination. Where I part ways is the framing: he presents it as something you reach for at scale. When &lt;code&gt;SeatReserved&lt;/code&gt; is saved in the same transaction as the state change, no effect can be lost forever: since the fact remains, the reaction can always be recreated — retry the handler, replay from the log. Outbox doesn&amp;rsquo;t create this persistence — it only uses it, automating the delivery with an at-least-once guarantee — which means subscribers must be idempotent. You don&amp;rsquo;t persist the fact because you scaled; you persist it from the first INSERT and harden delivery when the async scheduling gap becomes relevant.&lt;/p&gt;
&lt;h2 id="and_if_you_don__39_t_want_to_write_it_by_hand"&gt;And if you don&amp;rsquo;t want to write it by hand&lt;/h2&gt;
&lt;p&gt;The above is on bare &lt;em&gt;AR&lt;/em&gt; intentionally, so the argument can stand up without any library. But you get the same structure off the shelf. &lt;em&gt;Aggregate&lt;/em&gt; with &lt;em&gt;AggregateRoot&lt;/em&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Seat&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;AggregateRoot&lt;/span&gt;

  &lt;span class="no"&gt;AlreadyReserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;AlreadyReserved&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@reserved&lt;/span&gt;
    &lt;span class="n"&gt;apply&lt;/span&gt; &lt;span class="no"&gt;SeatReserved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;data: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;seat_id: &lt;/span&gt;&lt;span class="vi"&gt;@id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;reserved_by: &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="no"&gt;SeatReserved&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="vi"&gt;@reserved&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="vi"&gt;@reserved_by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:reserved_by&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;An invariant in &lt;code&gt;reserve&lt;/code&gt;, a fact in &lt;code&gt;SeatReserved&lt;/code&gt;, a state mutation in &lt;code&gt;on&lt;/code&gt; — and this is an &lt;em&gt;event&lt;/em&gt; that actually lands in the event store, with versioning and replay, not a notification whose name is derived from a namespace. You attach handlers (mailer, webhook) as subscribers exactly as above.&lt;/p&gt;

&lt;p&gt;And delivery? &lt;em&gt;RES&lt;/em&gt; publish is exactly &lt;code&gt;Events.publish&lt;/code&gt;: atomic write to the &lt;em&gt;event store&lt;/em&gt; plus synchronous dispatch, inside the caller&amp;rsquo;s transaction. &lt;code&gt;ruby_event_store-outbox&lt;/code&gt; patches the one gap I named above — transactional scheduling of async handlers — maintained, safe under concurrent workers, covered with mutation tests.&lt;/p&gt;

&lt;p&gt;A complete walkthrough of &lt;em&gt;RES&lt;/em&gt; — aggregates, subscriptions, outbox, strangler on an existing monolith — is a topic for a separate, much longer text. Let me preface this point, as Brandon rightly dislikes rewrites; this path isn&amp;rsquo;t rewriting. It&amp;rsquo;s strangler — precisely the incremental movement it describes, callback by callback, flag by flag. The punchline here is enough: the structure he&amp;rsquo;s approaching is available as a ready-made library, not an exotic one.&lt;/p&gt;
&lt;h2 id="where_weaver_is_right___and_what_i__39_m_not_saying"&gt;Where Weaver is right — and what I&amp;rsquo;m not saying&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m not advocating &amp;ldquo;always event sourcing&amp;rdquo;. That would be precisely the dogmatic approach I combat in people who sell &lt;em&gt;event sourcing&lt;/em&gt; as a religion.&lt;/p&gt;

&lt;p&gt;His &lt;em&gt;Transaction Script&lt;/em&gt; is sufficient for most applications. One team, one write path, reasonable discipline — and a procedure with a single input carries water for years. The Strangler fig + Flipper migrations he describes are really good. Normalizes for pure transformations — agree. Constraints as truth in the database — agree, and strongly so. His &lt;code&gt;CommandSingleEntrant&lt;/code&gt; RuboCop cop makes the single-entry rule structural rather than disciplinary — and he&amp;rsquo;s honest about its reach: &amp;ldquo;Both catch the common mistakes&amp;rdquo;, with &lt;code&gt;class &amp;lt;&amp;lt; self&lt;/code&gt; patterns as a known blind spot.&lt;/p&gt;

&lt;p&gt;My point isn&amp;rsquo;t &amp;ldquo;your pattern is too weak&amp;rdquo;. It&amp;rsquo;s: don&amp;rsquo;t call it a &lt;em&gt;command&lt;/em&gt; and &lt;em&gt;event&lt;/em&gt; when it isn&amp;rsquo;t. Because the difference between a &lt;em&gt;procedure&lt;/em&gt; and a &lt;em&gt;command&lt;/em&gt;, and between &lt;em&gt;notification&lt;/em&gt; and a &lt;em&gt;domain event&lt;/em&gt;, isn&amp;rsquo;t pedantry — it&amp;rsquo;s the difference between &amp;ldquo;an invariant has an owner&amp;rdquo; and &amp;ldquo;an invariant is smeared, but nicely named&amp;rdquo;.&lt;/p&gt;

&lt;p&gt;I anticipate three counterarguments:&lt;/p&gt;

&lt;p&gt;&amp;ldquo;It&amp;rsquo;s a dispute about names.&amp;rdquo; Yes — and names are a contract that will reach 500 people. A bad mental model scales worse than bad code, because code gets refactored, but beliefs don&amp;rsquo;t.&lt;/p&gt;

&lt;p&gt;&amp;ldquo;ES is overkill.&amp;rdquo; Agreed, for most people. But a persistent record of a fact isn&amp;rsquo;t &lt;em&gt;event sourcing&lt;/em&gt; — it&amp;rsquo;s a single &lt;code&gt;INSERT&lt;/code&gt; in the same transaction as a state change, turning &amp;ldquo;effect can be lost&amp;rdquo; into &amp;ldquo;effect always recoverable&amp;rdquo;. You don&amp;rsquo;t skip this because you&amp;rsquo;re not doing event sourcing; it&amp;rsquo;s record path hygiene, not architecture.&lt;/p&gt;

&lt;p&gt;Steelman 37signals — whom Weaver honestly quotes — says that disciplined callbacks scale further than they&amp;rsquo;re given credit for. In a single, cohesive team: they&amp;rsquo;re not wrong. But the same caveat applies to his solution: &lt;em&gt;Transaction Script&lt;/em&gt; without an invariant owner also relies on discipline, which is generally absent with 500 people. The aggregate approach doesn&amp;rsquo;t ask for discipline — the structure enforces the rule.&lt;/p&gt;
&lt;h2 id="landing"&gt;Landing&lt;/h2&gt;
&lt;p&gt;Single-ingress is correct. But an ingress that doesn&amp;rsquo;t own the invariant isn&amp;rsquo;t a fix — it&amp;rsquo;s moving the same rule spread from a callback to a procedure. And calling that &lt;em&gt;procedure&lt;/em&gt; a &lt;em&gt;command&lt;/em&gt; and the &lt;em&gt;notification&lt;/em&gt; an &lt;em&gt;event&lt;/em&gt; installs a mental model for 500 engineers that doesn&amp;rsquo;t fit the code they have in their hands.&lt;/p&gt;

&lt;p&gt;The strongest version of his own argument isn&amp;rsquo;t the one he wrote — it&amp;rsquo;s the one he&amp;rsquo;s getting closer to: an &lt;em&gt;aggregate&lt;/em&gt; that monitors the &lt;em&gt;invariant&lt;/em&gt;, a true &lt;em&gt;domain event&lt;/em&gt;, and a fact persisted in the same &lt;em&gt;transaction&lt;/em&gt; as the state change. Weaver ends with a CQRS announcement &amp;ldquo;next time,&amp;rdquo; and it&amp;rsquo;s a good announcement, because single-ingress without an invariant owner is only halfway down the road he&amp;rsquo;s charted.&lt;/p&gt;

&lt;p&gt;I look forward to the continuation of the series — honestly, without irony.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-06-11:/res-mcp-server/</id>
    <title type="html">Let Your AI Assistant Explore the Event Store with res-mcp</title>
    <published>2026-06-11T10:00:00Z</published>
    <updated>2026-06-11T10:00:00Z</updated>
    <author>
      <name>Tomasz Patrzek</name>
      <uri>https://blog.arkency.com/authors/tomasz-patrzek/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/res-mcp-server/" type="text/html"/>
    <content type="html">&lt;h1 id="let_your_ai_assistant_explore_the_event_store_with__code_res_mcp__code_"&gt;Let Your AI Assistant Explore the Event Store with &lt;code&gt;res-mcp&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;When you&amp;rsquo;re debugging with an AI assistant, half the work is giving it enough context. You copy event payloads into the chat, paste stream contents, look up IDs, then repeat the process every time you need another piece of information.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ruby_event_store-mcp&lt;/code&gt; removes that step. It&amp;rsquo;s the companion to the &lt;a href="https://blog.arkency.com/res-cli"&gt;res CLI&lt;/a&gt;. Instead of you querying the event store from the terminal, your AI assistant does it for you through &lt;a href="https://modelcontextprotocol.io/"&gt;MCP tools&lt;/a&gt;. You ask questions in plain English, it reads the events itself.&lt;/p&gt;

&lt;p&gt;The difference from copying the events into the chat yourself is that the assistant can ask follow-up questions on its own. If it needs to inspect another stream, load an aggregate&amp;rsquo;s history, or trace a correlation, it simply calls another tool. You stay in the conversation instead of switching between your AI client and a terminal.&lt;/p&gt;

&lt;!-- more --&gt;
&lt;h2 id="setup"&gt;Setup&lt;/h2&gt;
&lt;p&gt;Add the gem and install:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"ruby_event_store-mcp"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bundle install
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That installs the &lt;code&gt;res-mcp&lt;/code&gt; binary — but it doesn&amp;rsquo;t register anything with your AI client yet. The binary speaks MCP over &lt;strong&gt;stdio&lt;/strong&gt;, launched from your app&amp;rsquo;s root, where — exactly like the &lt;code&gt;res&lt;/code&gt; CLI — it loads &lt;code&gt;config/environment.rb&lt;/code&gt; and reads your app&amp;rsquo;s &lt;code&gt;Rails.configuration.event_store&lt;/code&gt;. There&amp;rsquo;s no HTTP endpoint to mount and nothing to deploy. Telling your client about it is a separate, one-time step — and every MCP client takes the same server definition, only the file it goes in changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt; — drop a &lt;code&gt;.mcp.json&lt;/code&gt; in your project root:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"res"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bundle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"exec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"res-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(or run &lt;code&gt;claude mcp add res -- bundle exec res-mcp&lt;/code&gt;). Launched from the project directory, Claude Code runs the server there, so no &lt;code&gt;cwd&lt;/code&gt; is needed. On the next launch it asks you to trust the project&amp;rsquo;s MCP server — approve it, then run &lt;code&gt;/mcp&lt;/code&gt; to see &lt;code&gt;res&lt;/code&gt; connected with its nine tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Desktop&lt;/strong&gt; — the same block, but with an explicit &lt;code&gt;cwd&lt;/code&gt; pointing at your app&amp;rsquo;s root, in &lt;code&gt;claude_desktop_config.json&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"res"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bundle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"exec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"res-mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cwd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/your/rails/app"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(macOS: &lt;code&gt;~/Library/Application Support/Claude/claude_desktop_config.json&lt;/code&gt;; Windows: &lt;code&gt;%APPDATA%\Claude\claude_desktop_config.json&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Other MCP clients&lt;/strong&gt; — Cursor, Windsurf, Cline and the rest take the same &lt;code&gt;mcpServers&lt;/code&gt; block in their own config file; VS Code&amp;rsquo;s built-in MCP uses a &lt;code&gt;servers&lt;/code&gt; key with &lt;code&gt;&amp;quot;type&amp;quot;: &amp;quot;stdio&amp;quot;&lt;/code&gt; instead. The &lt;code&gt;bundle exec res-mcp&lt;/code&gt; command is the portable part.&lt;/p&gt;

&lt;p&gt;That&amp;rsquo;s the whole setup. No routes, no mounts, no credentials.&lt;/p&gt;
&lt;h2 id="ask_questions__not_commands"&gt;Ask questions, not commands&lt;/h2&gt;
&lt;p&gt;Once it&amp;rsquo;s connected you just talk to the assistant — &lt;strong&gt;no slash command, no skill&lt;/strong&gt;. You ask in plain English and it decides which tools to call (the first call asks your permission; allowlist the &lt;code&gt;res&lt;/code&gt; server to stop being asked).&lt;/p&gt;

&lt;p&gt;The server gives the assistant nine &lt;strong&gt;read-only&lt;/strong&gt; tools over your event store. You never call them by name — you ask a question, and the assistant picks the tools it needs. They cover three kinds of questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browse streams and events&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;stream_show&lt;/code&gt; — a stream&amp;rsquo;s event count, version, and first/last event&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stream_events&lt;/code&gt; — the events in a stream, filterable by type, time, or position&lt;/li&gt;
&lt;li&gt;&lt;code&gt;event_show&lt;/code&gt; — one event in full, data and metadata&lt;/li&gt;
&lt;li&gt;&lt;code&gt;event_streams&lt;/code&gt; — every stream a given event belongs to&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Search and summarize&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;recent&lt;/code&gt; — the most recent events across the whole store (&amp;ldquo;what just happened?&amp;rdquo;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;search&lt;/code&gt; — events anywhere, filtered by type, time range, or stream&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stats&lt;/code&gt; — total counts and the unique event types present&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Follow a process&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;aggregate_history&lt;/code&gt; — the full event history of one aggregate instance (e.g. a single &lt;code&gt;Fulfillment::Order&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;trace&lt;/code&gt; — the causation tree of everything sharing a correlation ID, so you can see where a multi-step flow stopped&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="examples"&gt;Examples&lt;/h2&gt;
&lt;p&gt;For example, instead of browsing the streams yourself, you can simply ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&amp;ldquo;What just happened? Show the 20 most recent events.&amp;rdquo;&lt;/p&gt;

&lt;p&gt;&amp;ldquo;Walk me through the history of Fulfillment::Order f47ac10b-58cc-4372-a567-0e02b2c3d479.&amp;rdquo;&lt;/p&gt;

&lt;p&gt;&amp;ldquo;Are there any OrderPlaced events from the last hour without a matching OrderConfirmed?&amp;rdquo;&lt;/p&gt;

&lt;p&gt;&amp;ldquo;Trace correlation 452fd6f0-e3a2-4716-bc8a-43bbcf2cae61 — where did the process stop?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The assistant calls &lt;code&gt;recent&lt;/code&gt;, &lt;code&gt;aggregate_history&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt;, or &lt;code&gt;trace&lt;/code&gt; behind the scenes, reads the results, and reasons over them — no copy-pasting event payloads into the chat, no switching to a Rails console mid-thought.&lt;/p&gt;
&lt;h2 id="why_it__39_s_safe_to_point_at_any_app"&gt;Why it&amp;rsquo;s safe to point at any app&lt;/h2&gt;
&lt;p&gt;Every tool uses the public &lt;code&gt;event_store.*&lt;/code&gt; API — the same one your application uses through &lt;code&gt;Rails.configuration.event_store&lt;/code&gt;. There&amp;rsquo;s no direct SQL access, no ActiveRecord internals, and no adapter-specific code.&lt;/p&gt;

&lt;p&gt;The server is also intentionally read-only. Your AI assistant can inspect events, streams, and correlations, but it cannot append, link, or delete events. The worst it can do is answer your questions.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-06-02:/res-cli/</id>
    <title type="html">Inspect Your Event Store From the Terminal With the res CLI</title>
    <published>2026-06-02T10:00:00Z</published>
    <updated>2026-06-02T10:00:00Z</updated>
    <author>
      <name>Tomasz Patrzek</name>
      <uri>https://blog.arkency.com/authors/tomasz-patrzek/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/res-cli/" type="text/html"/>
    <content type="html">&lt;h1 id="inspect_your_event_store_from_the_terminal_with_the__code_res__code__cli"&gt;Inspect Your Event Store From the Terminal With the &lt;code&gt;res&lt;/code&gt; CLI&lt;/h1&gt;
&lt;p&gt;Because not every debugging session needs &lt;code&gt;rails c&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Most event store debugging starts the same way: you open a Rails console, try to remember the Event Store read API, and type something like:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Order$123"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It works, but it&amp;rsquo;s a lot of ceremony just to answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What happened in this stream?&lt;/li&gt;
&lt;li&gt;Was this event actually persisted?&lt;/li&gt;
&lt;li&gt;What does the latest event look like?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The new contrib gem, &lt;code&gt;ruby_event_store-cli&lt;/code&gt;, gives you a dedicated &lt;code&gt;res&lt;/code&gt; command for inspecting your Event Store directly from the terminal. No Rails console. No SQL queries. No custom rake tasks. Just a fast, convenient way to browse streams and events while you&amp;rsquo;re developing or debugging.&lt;/p&gt;

&lt;!-- more --&gt;

&lt;p&gt;Add it to your Gemfile:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"ruby_event_store-cli"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The CLI loads your Rails environment, so it connects to the same &lt;code&gt;Rails.configuration.event_store&lt;/code&gt; your application uses. Once it&amp;rsquo;s running, you can inspect streams, read events, search across the store, trace correlations, or watch new events arrive — all without leaving your terminal. Here&amp;rsquo;s a quick taste:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bundle exec res stream show Order$&amp;lt;order_id&amp;gt;
bundle exec res stream events Order$&amp;lt;order_id&amp;gt; --limit 20
bundle exec res event show 59588873-00fa-423f-89d1-03d7c3b0ab35
bundle exec res search --type OrderPlaced --after 2026-03-01T00:00:00Z
bundle exec res stats
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Say an order never gets confirmed. Instead of reaching for &lt;code&gt;rails c&lt;/code&gt;, you can walk the whole story from the terminal:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bundle exec res stream show Order$&amp;lt;order_id&amp;gt;
bundle exec res stream events Order$&amp;lt;order_id&amp;gt;
bundle exec res trace &amp;lt;correlation_id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;In under a minute you&amp;rsquo;ve gone from &amp;ldquo;something&amp;rsquo;s wrong&amp;rdquo; to seeing exactly where the workflow stopped. The rest of this post walks through each command in detail.&lt;/p&gt;
&lt;h2 id="streams"&gt;Streams&lt;/h2&gt;&lt;h3 id="_code_stream_show__code____a_stream_at_a_glance"&gt;&lt;code&gt;stream show&lt;/code&gt; — a stream at a glance&lt;/h3&gt;
&lt;p&gt;Event count, current version, and the first and last event:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bundle exec res stream show 'Order$f47ac10b-58cc-4372-a567-0e02b2c3d479'
Stream:  Order$f47ac10b-58cc-4372-a567-0e02b2c3d479
Events:  2
Version: 1
First:   2026-03-24T17:45:03.891Z (OrderPlaced)
Last:    2026-03-24T17:46:12.004Z (OrderConfirmed)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="_code_stream_events__code____read_the_events"&gt;&lt;code&gt;stream events&lt;/code&gt; — read the events&lt;/h3&gt;
&lt;p&gt;By default it prints the stream as a table:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bundle exec res stream events Order$&amp;lt;order_id&amp;gt;
EVENT ID                              TYPE                                      TIMESTAMP
------------------------------------------------------------------------------------------
59588873-00fa-423f-89d1-03d7c3b0ab35  OrderPlaced                               2026-03-24T17:45:03.891Z
6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f  OrderConfirmed                            2026-03-24T17:46:12.004Z

2 event(s)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Narrow it down with filters — they combine freely:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Filter by event type
bundle exec res stream events Order$&amp;lt;order_id&amp;gt; --type OrderPlaced

# Newer than a timestamp (ISO8601)
bundle exec res stream events Order$&amp;lt;order_id&amp;gt; --after 2026-03-01T00:00:00Z

# Older than a timestamp
bundle exec res stream events Order$&amp;lt;order_id&amp;gt; --before 2026-04-01T00:00:00Z

# Cap the number of events (default: 50)
bundle exec res stream events Order$&amp;lt;order_id&amp;gt; --limit 10

# Paginate — start after a known event ID
bundle exec res stream events Order$&amp;lt;order_id&amp;gt; --from 3fa85f64-5717-4562-b3fc-2c963f66afa6
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Add &lt;code&gt;--follow&lt;/code&gt; (or &lt;code&gt;-f&lt;/code&gt;) to keep the command running and print new events in this stream as they arrive — a live tail of a single stream:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bundle exec res stream events Order$&amp;lt;order_id&amp;gt; --follow
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="events"&gt;Events&lt;/h2&gt;&lt;h3 id="_code_event_show__code____everything_about_one_event"&gt;&lt;code&gt;event show&lt;/code&gt; — everything about one event&lt;/h3&gt;
&lt;p&gt;Full detail for a single event ID, data and metadata included:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bundle exec res event show 59588873-00fa-423f-89d1-03d7c3b0ab35
Event ID:   59588873-00fa-423f-89d1-03d7c3b0ab35
Type:       OrderPlaced
Timestamp:  2026-03-24T17:45:03.891Z
Valid at:   2026-03-24T17:45:03.891Z
Data:       {
  "order_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "customer_id": "c-1024",
  "total": "149.00"
}
Metadata:   {
  "correlation_id": "452fd6f0-e3a2-4716-bc8a-43bbcf2cae61",
  "causation_id": "452fd6f0-e3a2-4716-bc8a-43bbcf2cae61"
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="_code_event_streams__code____where_an_event_lives"&gt;&lt;code&gt;event streams&lt;/code&gt; — where an event lives&lt;/h3&gt;
&lt;p&gt;An event is published to one stream and can be linked into many. This lists every stream it belongs to:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bundle exec res event streams 59588873-00fa-423f-89d1-03d7c3b0ab35
Order$f47ac10b-58cc-4372-a567-0e02b2c3d479
$by_type_OrderPlaced
$by_correlation_id_452fd6f0-e3a2-4716-bc8a-43bbcf2cae61
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="search_across_the_store"&gt;Search across the store&lt;/h2&gt;&lt;h3 id="_code_search__code____find_events_anywhere"&gt;&lt;code&gt;search&lt;/code&gt; — find events anywhere&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;stream events&lt;/code&gt; is scoped to one stream; &lt;code&gt;search&lt;/code&gt; looks across the whole store. Same filters — &lt;code&gt;--type&lt;/code&gt;, &lt;code&gt;--after&lt;/code&gt;, &lt;code&gt;--before&lt;/code&gt;, &lt;code&gt;--limit&lt;/code&gt; — plus &lt;code&gt;--stream&lt;/code&gt; to scope back down when you want to:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bundle exec res search --type OrderPlaced --after 2026-03-01T00:00:00Z
EVENT ID                              TYPE                                      TIMESTAMP
------------------------------------------------------------------------------------------
59588873-00fa-423f-89d1-03d7c3b0ab35  OrderPlaced                               2026-03-24T17:45:03.891Z
8b1f2c3d-1a2b-4c3d-9e8f-7a6b5c4d3e2f  OrderPlaced                               2026-03-22T09:13:55.620Z

2 event(s)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="_code_stats__code____counts_and_types"&gt;&lt;code&gt;stats&lt;/code&gt; — counts and types&lt;/h3&gt;
&lt;p&gt;Total event count and the unique event types present — across the store, or for one stream with &lt;code&gt;--stream&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bundle exec res stats
Events:  1432

Event types:
  OrderCancelled
  OrderConfirmed
  OrderPlaced
  PaymentAuthorized
  ShipmentScheduled
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bundle exec res stats --stream Order$&amp;lt;order_id&amp;gt;
Stream:  Order$f47ac10b-58cc-4372-a567-0e02b2c3d479
Events:  2

Event types:
  OrderConfirmed
  OrderPlaced
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="json_output"&gt;JSON output&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;stream events&lt;/code&gt; and &lt;code&gt;search&lt;/code&gt; can both emit JSON instead of a table with &lt;code&gt;--format json&lt;/code&gt;. That makes them easy to combine with tools like &lt;a href="https://jqlang.github.io/jq/"&gt;&lt;code&gt;jq&lt;/code&gt;&lt;/a&gt; or pipe into your own scripts:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# What data did our last OrderPlaced carry?
bundle exec res stream events Order$&amp;lt;order_id&amp;gt; \
  --type OrderPlaced \
  --limit 1 \
  --format json \
  | jq '.[0].data'

# Any events with missing customer_id?
bundle exec res search --type OrderPlaced --format json \
  | jq '.[] | select(.data.customer_id == null)'

# Unique event types in a stream
bundle exec res stream events Order$&amp;lt;order_id&amp;gt; --format json \
  | jq '[.[].event_type] | unique'
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Each JSON event carries &lt;code&gt;event_id&lt;/code&gt;, &lt;code&gt;event_type&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;metadata&lt;/code&gt;, and &lt;code&gt;timestamp&lt;/code&gt; — the keys the &lt;code&gt;jq&lt;/code&gt; filters above reach into.&lt;/p&gt;
&lt;h2 id="trace_correlated_events"&gt;Trace correlated events&lt;/h2&gt;
&lt;p&gt;Give &lt;code&gt;res trace&lt;/code&gt; a correlation ID and it gathers every event sharing it, then shapes them by causation — which event triggered which — into a tree:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bundle exec res trace 452fd6f0-e3a2-4716-bc8a-43bbcf2cae61
OrderPlaced [452fd6f0-e3a2-4716-bc8a-43bbcf2cae61]
├── PaymentAuthorized [7c9e6679-7425-40de-944b-e07fc1f90ae7]
│   └── OrderConfirmed [a1b2c3d4-5e6f-4a8b-9c0d-1e2f3a4b5c6d]
└── ShipmentScheduled [9f8e7d6c-5b4a-4938-8271-6f5e4d3c2b1a]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The tree makes it obvious where a workflow stopped. If a branch is missing, you immediately know which handler never published its follow-up event — without digging through logs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;trace&lt;/code&gt; relies on correlation linking. As related events are published, Rails Event Store links them into a &lt;code&gt;$by_correlation_id_...&lt;/code&gt; stream while preserving correlation and causation IDs. The command simply reconstructs the tree from those links.&lt;/p&gt;
&lt;h2 id="watch_events_live"&gt;Watch events live&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;res watch&lt;/code&gt; gives you a live view of everything happening in the event store, grouped by bounded-context namespace (the part before &lt;code&gt;::&lt;/code&gt; in the event type). Handy during demos, load tests, or while reproducing a multi-step flow:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bundle exec res watch
Fulfillment (2 events)
  OrderPlaced                     17:45:03  452fd6f0-e3a2-4716-bc8a-43bbcf2cae61
  OrderConfirmed                  17:45:04  a1b2c3d4-5e6f-4a8b-9c0d-1e2f3a4b5c6d

Payments (1 events)
  PaymentAuthorized               17:45:03  7c9e6679-7425-40de-944b-e07fc1f90ae7

Watching since 17:45:00 — Press Ctrl+C to exit
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The display updates in place instead of scrolling. A few flags control what it shows and how often:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--namespace Fulfillment,Payments&lt;/code&gt; — only show those namespaces&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--since 2026-03-24T17:00:00Z&lt;/code&gt; — start from a timestamp instead of &amp;ldquo;now&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--limit 50&lt;/code&gt; — max events shown per namespace (default: 50)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--interval 1&lt;/code&gt; — refresh interval in seconds (default: 1)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where &lt;code&gt;stream events --follow&lt;/code&gt; tails one stream, &lt;code&gt;watch&lt;/code&gt; gives you the whole store at a glance — two complementary live views.&lt;/p&gt;
&lt;h2 id="the_full_command_set"&gt;The full command set&lt;/h2&gt;
&lt;p&gt;Eight commands in this first release, all reading through the public &lt;code&gt;event_store&lt;/code&gt; API — no SQL, no adapter internals:&lt;/p&gt;

&lt;table&gt;&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stream show&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Event count, version, and first/last event of a stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stream events&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read a stream&amp;rsquo;s events, with filtering, pagination, and &lt;code&gt;--follow&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;event show&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full detail of one event — data, metadata, timestamps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;event streams&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every stream an event is published or linked to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Find events across all streams by type, time, or stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stats&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Total count and unique event types, store-wide or per stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Causation tree for everything sharing a correlation ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;watch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live feed of new events, grouped by namespace&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;

&lt;p&gt;The CLI intentionally stays small. Every command goes through the public Rails Event Store API, so it works regardless of the storage adapter underneath. If you spend your day debugging event-driven applications, it&amp;rsquo;s often the quickest way to answer &amp;ldquo;what happened?&amp;rdquo; without opening &lt;code&gt;rails c&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="see_also"&gt;See also&lt;/h2&gt;
&lt;p&gt;Prefer letting an AI assistant do the reading? Its companion gem, &lt;a href="https://blog.arkency.com/res-mcp-server"&gt;&lt;code&gt;res-mcp&lt;/code&gt;&lt;/a&gt;, exposes the same event store as &lt;a href="https://modelcontextprotocol.io/"&gt;MCP tools&lt;/a&gt; — you ask in plain English instead of typing commands.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-06-02:/whats-new-in-rails-event-store-3/</id>
    <title type="html">What's New in Rails Event Store 3.0</title>
    <published>2026-06-02T10:00:00Z</published>
    <updated>2026-06-02T10:00:00Z</updated>
    <author>
      <name>Tomasz Patrzek</name>
      <uri>https://blog.arkency.com/authors/tomasz-patrzek/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/whats-new-in-rails-event-store-3/" type="text/html"/>
    <content type="html">&lt;h1 id="what__39_s_new_in_rails_event_store_3_0"&gt;What&amp;rsquo;s New in Rails Event Store 3.0&lt;/h1&gt;
&lt;p&gt;Rails Event Store 3.0 is primarily a cleanup release.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you&amp;rsquo;ve already addressed all deprecation warnings in 2.19, upgrading to 3.0 should be straightforward. This post summarizes what changed, while the &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/"&gt;2.19 release announcement&lt;/a&gt; explains the motivation behind each deprecation in more detail.&lt;/p&gt;

&lt;!-- more --&gt;

&lt;hr&gt;
&lt;h2 id="lean_api__the_deprecations_are_gone"&gt;Lean API: the deprecations are gone&lt;/h2&gt;
&lt;p&gt;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&amp;rsquo;s the at-a-glance list of what&amp;rsquo;s removed and what replaces it (each row links to the reasoning in the 2.19 post):&lt;/p&gt;

&lt;table&gt;&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Removed&lt;/th&gt;
&lt;th&gt;Use instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read.in_batches_of(100)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;read.in_batches(100)&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#_code_in_batches_of__code_" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read.of_types([Type])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;read.of_type(Type)&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#_code_of_types__code_" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RubyEventStore::ImmediateAsyncDispatcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RubyEventStore::ImmediateDispatcher&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#dispatcher_naming" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RailsEventStore::AfterCommitAsyncDispatcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RailsEventStore::AfterCommitDispatcher&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#dispatcher_naming" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RubyEventStore::Dispatcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RubyEventStore::SyncScheduler&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#dispatcher_naming" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subscribe(Handler, to: [Type])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;subscribe(Handler.new, to: [Type])&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#class_based_subscribers" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Mappers::NullMapper.new&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Mappers::Default.new&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#_code_nullmapper__code_" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;def apply_order_placed(event)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on(OrderPlaced) { ... }&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#_code_apply____code__method_convention" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AggregateRoot::Configuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AggregateRoot::Repository.new(event_store)&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#_code_aggregateroot__configuration__code_____code_default_event_store__code_" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code style="display:block;white-space:pre-wrap"&gt;read.repository.rails_event_store&lt;br&gt;call.dispatcher.rails_event_store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code style="display:block;white-space:pre-wrap"&gt;read.repository.ruby_event_store&lt;br&gt;call.dispatcher.ruby_event_store&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#_code___rails_event_store__code__instrumentation_events" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code style="display:block;white-space:pre-wrap"&gt;Projection&lt;br&gt;  .from_stream(&amp;quot;Orders&amp;quot;)&lt;br&gt;  .when(OrderPlaced, handler)&lt;br&gt;  .run(event_store)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code style="display:block;white-space:pre-wrap"&gt;Projection&lt;br&gt;  .init({ count: 0 })&lt;br&gt;  .on(OrderPlaced, &amp;amp;handler)&lt;br&gt;  .call(event_store.read.stream(&amp;quot;Orders&amp;quot;))&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#projection_api" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code style="display:block;white-space:pre-wrap"&gt;RailsEventStore::Event&lt;br&gt;RailsEventStore::Projection&lt;br&gt;RailsEventStore::InMemoryRepository&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code style="display:block;white-space:pre-wrap"&gt;RubyEventStore::Event&lt;br&gt;RubyEventStore::Projection&lt;br&gt;RubyEventStore::InMemoryRepository&lt;/code&gt; &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#_code_railseventstore_____code__constant_aliases" style="font-style:italic;color:#000;font-size:.85em"&gt;details&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note —&lt;/strong&gt; removing the aliases doesn&amp;rsquo;t touch the genuinely Rails-specific classes. &lt;code&gt;RailsEventStore::Client&lt;/code&gt; and &lt;code&gt;RailsEventStore::AfterCommitDispatcher&lt;/code&gt; aren&amp;rsquo;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 &lt;code&gt;RubyEventStore::&lt;/code&gt; original are gone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The one replacement that isn&amp;rsquo;t a rename is &lt;code&gt;EventClassRemapper&lt;/code&gt; — its successor, upcasting, needs a real handler.&lt;/p&gt;
&lt;h3 id="eventclassremapper_is_gone___use_upcasting"&gt;EventClassRemapper is gone — use upcasting&lt;/h3&gt;
&lt;p&gt;In event sourcing, events are immutable facts — once written, a record&amp;rsquo;s &lt;code&gt;event_type&lt;/code&gt; stays as it was, a plain string you never go back and rewrite. By convention that string is the class name, and that&amp;rsquo;s exactly what a rename breaks: move &lt;code&gt;OrderPlaced&lt;/code&gt; into an &lt;code&gt;Ordering&lt;/code&gt; module and the events already stored as &lt;code&gt;&amp;quot;OrderPlaced&amp;quot;&lt;/code&gt; no longer resolve to &lt;code&gt;Ordering::OrderPlaced&lt;/code&gt; on read.&lt;/p&gt;

&lt;p&gt;In 2.x you patched this on read with the &lt;code&gt;events_class_remapping:&lt;/code&gt; option — a string-to-string lookup:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 2.x — removed&lt;/span&gt;
&lt;span class="no"&gt;RubyEventStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Mappers&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;events_class_remapping: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"OrderPlaced"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Ordering::OrderPlaced"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;3.0 replaces it with the upcasting transformation. A &lt;code&gt;Record&lt;/code&gt; is immutable, so the upcast lambda receives the old record and returns a brand-new one — for a rename you change only &lt;code&gt;event_type&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 3.0&lt;/span&gt;
&lt;span class="n"&gt;upcast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RubyEventStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Mappers&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Transformation&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Upcast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;"OrderPlaced"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;RubyEventStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;event_type: &lt;/span&gt;&lt;span class="s2"&gt;"Ordering::OrderPlaced"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;data:       &lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;event_id:   &lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;metadata:   &lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;timestamp:  &lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;valid_at:   &lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valid_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;mapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RubyEventStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Mappers&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;upcast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="no"&gt;RubyEventStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Mappers&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Transformation&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SymbolizeMetadataKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="no"&gt;RailsEventStore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;mapper: &lt;/span&gt;&lt;span class="n"&gt;mapper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Starting from a full &lt;code&gt;Record&lt;/code&gt; is the whole point: it lets you do far more than rename the type. You can reshape &lt;code&gt;data&lt;/code&gt; 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.&lt;/p&gt;
&lt;h3 id="other_removals"&gt;Other removals&lt;/h3&gt;
&lt;p&gt;A handful of smaller cleanups round out the release, grouped by where they&amp;rsquo;d reach you — chances are most won&amp;rsquo;t.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you subscribe to instrumentation&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;If you customize mappers or aggregates&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Stricter by default&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;And one warning simply went away — the spurious &lt;code&gt;rails_event_store_active_record&lt;/code&gt; rename warning is gone (&lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#deprecation_warnings_in_tests"&gt;backstory in the 2.19 post&lt;/a&gt;).&lt;/p&gt;

&lt;hr&gt;
&lt;h2 id="upgrade_guide"&gt;Upgrade guide&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Projection API&lt;/strong&gt; — define the projection once, then call it with any scope:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before&lt;/span&gt;
&lt;span class="no"&gt;Projection&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Orders"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;OrderPlaced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# after&lt;/span&gt;
&lt;span class="no"&gt;Projection&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="ss"&gt;count: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;OrderPlaced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Orders"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;AggregateRoot &lt;code&gt;default_event_store&lt;/code&gt;&lt;/strong&gt; — the global default is gone; wire the event store explicitly through &lt;code&gt;AggregateRoot::Repository.new(event_store)&lt;/code&gt;. &lt;a href="/railseventstore-2-dot-19-starting-gun-for-3-dot-0/#_code_aggregateroot__configuration__code_____code_default_event_store__code_"&gt;Details in the 2.19 post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;EventClassRemapper&lt;/code&gt;&lt;/strong&gt; — replace the &lt;code&gt;events_class_remapping:&lt;/code&gt; hash with an upcasting transformation on your mapper, as shown in the &lt;a href="#eventclassremapper_is_gone___use_upcasting"&gt;upcasting section&lt;/a&gt; above.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-05-31:/storing-data-as-a-sequence-of-events-ruby-conf-austria-lightning-talk-supplement/</id>
    <title type="html">Storing data as a sequence of events - RubyConfAt lightning talk supplement</title>
    <published>2026-05-31T13:42:04Z</published>
    <updated>2026-05-31T13:42:04Z</updated>
    <author>
      <name>Łukasz Reszke</name>
      <uri>https://blog.arkency.com/authors/lukasz-reszke/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/storing-data-as-a-sequence-of-events-ruby-conf-austria-lightning-talk-supplement/" type="text/html"/>
    <content type="html">&lt;h1 id="storing_data_as_a_sequence_of_events___rubyconfat_lightning_talk_supplement"&gt;Storing data as a sequence of events - RubyConfAt lightning talk supplement&lt;/h1&gt;
&lt;p&gt;This post originated from the lightning talk and the discussions I had afterward at &lt;a href="http://rubyconf.at"&gt;RubyConfAt&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;It was a great conference. Especially the music part was amazing. Big kudos and applause to the organizers, once again! &lt;/p&gt;
&lt;h2 id="the_lightning_talk"&gt;The Lightning Talk&lt;/h2&gt;
&lt;p&gt;During my lightning talk I discussed an alternative way of storing data in Ruby applications. The alternative way is to store data as a sequence of events. &lt;/p&gt;

&lt;p&gt;It all starts with admitting the fact that an &lt;strong&gt;update of data causes information loss&lt;/strong&gt;. We do know &lt;strong&gt;what&lt;/strong&gt; it is now - we see the current state in a database column. But we don’t know &lt;strong&gt;how&lt;/strong&gt; we got there. Context is lost.&lt;/p&gt;

&lt;p&gt;Event sourcing solves that problem by storing each change as an event in our database. &lt;/p&gt;
&lt;h2 id="what_is_an_event_"&gt;What is an event?&lt;/h2&gt;
&lt;p&gt;An event represents a fact, something that happened in our system. It’s &lt;strong&gt;immutable&lt;/strong&gt; - once persisted, it cannot be changed.&lt;/p&gt;

&lt;p&gt;An event consists of a name, event_id, data, and metadata.&lt;/p&gt;

&lt;p&gt;An example of an event is &lt;code&gt;ShipmentPacked&lt;/code&gt;. This event tells us, well, that a shipment has been packed. No surprise. And that’s also an important aspect. We want event names to be self-explanatory and follow the language that business speaks. Having an event named &lt;code&gt;ShipmentUpdated&lt;/code&gt; is not a good event sourcing event name. It’s too vague. It doesn&amp;rsquo;t tell us what happened in the system.&lt;/p&gt;
&lt;h3 id="common_misconception__1___immutable_is_problem"&gt;Common misconception #1 - Immutable is problem&lt;/h3&gt;
&lt;p&gt;The first common misconception that has to be addressed is this:
The fact that the event is immutable doesn’t mean you cannot change data!&lt;/p&gt;

&lt;p&gt;Event sourcing draws from the accounting domain in that case. 
If you make a mistake with the data, you make a correction. &lt;/p&gt;

&lt;p&gt;The cool part also comes when you analyze different kinds of mistakes and their consequences. &lt;/p&gt;

&lt;p&gt;Consider the following: a developer made a mistake in the code. Ok, so then they write migration script to address it. &amp;hellip;Unfortunately they make another mistake. &lt;/p&gt;

&lt;p&gt;&lt;em&gt;Oshit.jpg what now?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It’s up to you. You can either start from the beginning (before the first attempt) or continue from the second mistake. All the data is already in place. You do what’s easier. There are patterns to deal with that so you don’t need to reinvent the wheel. &lt;/p&gt;
&lt;h2 id="event_sourcing"&gt;Event Sourcing&lt;/h2&gt;
&lt;p&gt;I highlighted that during my lightning talk and I’ll do it again here. Better twice than sorry :)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event sourcing is just another persistence technique.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of storing current state, events are stored. 
Those events are used to rebuild the state when needed. 
A decision is made based on the state. 
Just like with the CRUD approach we’re used to. 
The difference is state rebuilding. &lt;/p&gt;
&lt;h3 id="common_misconception__2___rebuilding_starts_from_the_beginning_of_the_universe"&gt;Common misconception #2 - rebuilding starts from the beginning of the universe&lt;/h3&gt;
&lt;p&gt;There’s a common misconception that I need to address, though. To rebuild the state &lt;strong&gt;you don’t need to load all events since the beginning of the universe.&lt;/strong&gt; You just read the exact events that you need to make a decision. No more. And this is where streams kick in. &lt;/p&gt;
&lt;h2 id="streams"&gt;Streams&lt;/h2&gt;
&lt;p&gt;A stream is a logical representation of a business concept. 
It gathers all events belonging to that concept.&lt;/p&gt;

&lt;p&gt;In the packing example, it&amp;rsquo;s all about packing a specific shipment, with id 1234. 
Then the &lt;code&gt;ShipmentPacked&lt;/code&gt; event would belong to the &lt;code&gt;Shipment$1234&lt;/code&gt; stream. &lt;/p&gt;

&lt;p&gt;&lt;em&gt;Side note: the dollar sign is just stream naming convention. It’s not necessary.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You can think about a stream similarly to an entity represented by an &lt;code&gt;ActiveRecord&lt;/code&gt; model. It’s just built differently using so-called projections. &lt;/p&gt;

&lt;p&gt;Projections read all events from a specific stream to build the state of an object and make a specific decision. &lt;/p&gt;
&lt;h3 id="another_case_for_streams"&gt;Another case for streams&lt;/h3&gt;
&lt;p&gt;Representing a specific business concept is not the only use case for a stream.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://railseventstore.org/"&gt;RailsEventStore&lt;/a&gt; an event can be linked to multiple streams. 
You can use this technique to answer interesting business questions.&lt;/p&gt;

&lt;p&gt;Let’s look at a specific example. &lt;/p&gt;

&lt;p&gt;Imagine you&amp;rsquo;re organizing a conference in Vienna. 
You could be interested in the number of tickets sold on a specific day. 
To achieve that you should link all &lt;code&gt;TicketPurchased&lt;/code&gt; events to &lt;code&gt;Tickets_sold_at_${Date.current}&lt;/code&gt; stream.&lt;/p&gt;

&lt;p&gt;We can go even further. If we had other events representing speaker, talk, agenda, or fun activities announcement, you could link all those events together, build a timeline (or feed an agent), and figure out what has the biggest impact on sales. &lt;/p&gt;

&lt;p&gt;Cool, isn’t it? &lt;/p&gt;
&lt;h2 id="common_misconception__3___it__39_s_slow"&gt;Common misconception #3 - it&amp;rsquo;s slow&lt;/h2&gt;
&lt;p&gt;It must be slow to display data, isn’t it? &lt;/p&gt;

&lt;p&gt;No, it is not. And more often it will be even faster than a regular approach. 
That’s actually what Andrzej mentioned in his talk. &lt;/p&gt;

&lt;p&gt;Use events to build read models. &lt;/p&gt;

&lt;p&gt;The way we often do it is that we move the pressure from reads to writes. This means that instead of building data for view on the fly (usually causing long-running query for more complex views), we’ll prepare the read model that the user displays upfront, based on specific events. &lt;/p&gt;
&lt;h2 id="common_misconception__4___i_need_new_infra"&gt;Common misconception #4 - I need new infra&lt;/h2&gt;
&lt;p&gt;Luckily, you don&amp;rsquo;t. In one of previous paragraphs I mentioned &lt;a href="https://railseventstore.org/"&gt;RailsEventStore&lt;/a&gt;. 
It is a gem that you can use to implement event sourcing in your current Rails application.&lt;/p&gt;

&lt;p&gt;Few facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It works with PostgreSQL, MySQL, SQLite&lt;/li&gt;
&lt;li&gt;It comes with migrations – it&amp;rsquo;ll create both events and streams table for you&lt;/li&gt;
&lt;li&gt;It comes with &lt;a href="https://railseventstore.org/docs/getting-started/install#existing-rails-application"&gt;predefined template&lt;/a&gt; that you can use to plug it into your existing Rails application&lt;/li&gt;
&lt;li&gt;It doesn&amp;rsquo;t require any additional bit of infrastructure&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="summary"&gt;Summary&lt;/h2&gt;
&lt;p&gt;There are many more aspects of event sourcing that I’d love to discuss in more detail. However, this is a good start. In general, I am very happy that we are starting to talk more about events in the Ruby community and that we allow ourselves to think outside of the Rails Way. I hope that I’ll have a chance to discuss more interesting aspects like anti-patterns, case studies with happy endings that had some stress involved (aka fuckup stories) in upcoming conferences! &lt;/p&gt;

&lt;p&gt;But before this happens, I must write one more thing. Event sourcing is a tool that you should have in your toolbox. But it’s not a silver bullet. In my experience, it makes the important parts of an application better in many different ways. However, I do not apply it everywhere, nor do I recommend doing that. &lt;/p&gt;
&lt;h3 id="benefits_recap"&gt;Benefits recap&lt;/h3&gt;
&lt;p&gt;To recap the benefits I’ve talked about during the lightning talk: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No information is lost. Moreover, the data is richer because there’s additional metadata describing it&lt;/li&gt;
&lt;li&gt;It’s auditable &lt;/li&gt;
&lt;li&gt;Debugging gets easier. You can see exactly how bits of your data changed, when they changed, by whom, and what caused each change. Super useful when you have business processes that involve multiple steps &lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
</feed>

