<?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-05-19T12:56:25Z</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-05-19:/railseventstore-2-dot-19-starting-gun-for-3-dot-0/</id>
    <title type="html">RailsEventStore 2.19: Starting Gun for 3.0</title>
    <published>2026-05-19T12:56:25Z</published>
    <updated>2026-05-19T12:56:25Z</updated>
    <author>
      <name>Szymon Fiedler</name>
      <uri>https://blog.arkency.com/authors/szymon-fiedler/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/railseventstore-2-dot-19-starting-gun-for-3-dot-0/" type="text/html"/>
    <content type="html">&lt;h1 id="railseventstore_2_19__starting_gun_for_3_0"&gt;RailsEventStore 2.19: Starting Gun for 3.0&lt;/h1&gt;
&lt;p&gt;RailsEventStore 2.19.1 is out — grab that one, not 2.19.0 (more on why below).&lt;/p&gt;

&lt;p&gt;This release is the starting gun for 3.0. We&amp;rsquo;ve added deprecation warnings for everything we&amp;rsquo;re removing in the next major version. Run your test suite — every warning you see is a hard error in 3.0.&lt;/p&gt;

&lt;!-- more --&gt;
&lt;h2 id="deprecations"&gt;Deprecations&lt;/h2&gt;
&lt;p&gt;We&amp;rsquo;re deprecating a batch of APIs in 2.19 that will be removed in 3.0.&lt;/p&gt;
&lt;h3 id="rubyeventstore"&gt;RubyEventStore&lt;/h3&gt;&lt;h4 id="_code_in_batches_of__code_"&gt;&lt;code&gt;in_batches_of&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Renamed to &lt;code&gt;in_batches&lt;/code&gt; for consistency with the rest of the API.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# deprecated&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;in_batches_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&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;batch&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# use instead&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;in_batches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&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;batch&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h4 id="_code_of_types__code_"&gt;&lt;code&gt;of_types&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Renamed to &lt;code&gt;of_type&lt;/code&gt;. Singular, consistent with other query methods.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# deprecated&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;of_types&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="no"&gt;OrderShipped&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# use instead&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;of_type&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="no"&gt;OrderShipped&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h4 id="projection_api"&gt;Projection API&lt;/h4&gt;
&lt;p&gt;The old API coupled projection definition to the data source upfront — you had to specify the stream when building the projection. The new API separates these concerns: define the projection once, call it with any scope from &lt;code&gt;event_store.read&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;# deprecated&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;"Order$1"&lt;/span&gt;&lt;span class="p"&gt;)&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&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;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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&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="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;# use instead&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&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;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&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="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;"Order$1"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Also deprecated: calling &lt;code&gt;Projection#call&lt;/code&gt; with multiple scopes — pass a single scope. Use &lt;code&gt;Projection.init&lt;/code&gt; instead of &lt;code&gt;Projection.new&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id="class_based_subscribers"&gt;Class-based subscribers&lt;/h4&gt;
&lt;p&gt;Passing a class as a subscriber hides the lifecycle — RES had to decide when and how to instantiate it, with no control from the caller. An instance or lambda is explicit about what gets called and when.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# deprecated&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;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SendOrderConfirmation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;to: &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="c1"&gt;# use instead&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;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SendOrderConfirmation&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;to: &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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h4 id="_code_eventclassremapper__code_____code_events_class_remapping___code_"&gt;&lt;code&gt;EventClassRemapper&lt;/code&gt; / &lt;code&gt;events_class_remapping:&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Upcasting has been available since RES 2.1.0 and is the proper way to handle renamed or evolved event classes. It&amp;rsquo;s composable, co-located with the transformation logic, and doesn&amp;rsquo;t require configuring the mapper globally. &lt;code&gt;EventClassRemapper&lt;/code&gt; is being removed.&lt;/p&gt;
&lt;h4 id="_code_nullmapper__code_"&gt;&lt;code&gt;NullMapper&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Mappers::Default.new&lt;/code&gt; without arguments does exactly what &lt;code&gt;NullMapper&lt;/code&gt; did, with a name that doesn&amp;rsquo;t imply it does nothing.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# deprecated&lt;/span&gt;
&lt;span class="ss"&gt;mapper: &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;NullMapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;

&lt;span class="c1"&gt;# use instead&lt;/span&gt;
&lt;span class="ss"&gt;mapper: &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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="railseventstore"&gt;RailsEventStore&lt;/h3&gt;&lt;h4 id="_code_railseventstore_____code__constant_aliases"&gt;&lt;code&gt;RailsEventStore::*&lt;/code&gt; constant aliases&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;rails_event_store&lt;/code&gt; gem is an integration layer — it wires RES into Rails. The domain objects (events, client, projections) live in &lt;code&gt;ruby_event_store&lt;/code&gt;. Aliasing them under the &lt;code&gt;RailsEventStore&lt;/code&gt; namespace implied they were Rails-specific, which caused confusion about what&amp;rsquo;s portable and what isn&amp;rsquo;t. In 3.0 those aliases are gone — use the source namespace directly.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# deprecated&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;Event&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;JSONClient&lt;/span&gt;

&lt;span class="c1"&gt;# use instead&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;Event&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;JSONClient&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h4 id="_code___rails_event_store__code__instrumentation_events"&gt;&lt;code&gt;*.rails_event_store&lt;/code&gt; instrumentation events&lt;/h4&gt;
&lt;p&gt;Same reason as above — the implementation is in &lt;code&gt;ruby_event_store&lt;/code&gt;, so the instrumentation namespace should be too. During the 2.19 transition period both &lt;code&gt;*.rails_event_store&lt;/code&gt; and &lt;code&gt;*.ruby_event_store&lt;/code&gt; are dual-fired. After 3.0 only &lt;code&gt;*.ruby_event_store&lt;/code&gt; remains — update your &lt;code&gt;ActiveSupport::Notifications&lt;/code&gt; subscriptions.&lt;/p&gt;
&lt;h4 id="dispatcher_naming"&gt;Dispatcher naming&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;Async&lt;/code&gt; in &lt;code&gt;ImmediateAsyncDispatcher&lt;/code&gt; and &lt;code&gt;AfterCommitAsyncDispatcher&lt;/code&gt; described the handler (a background job), not the dispatcher itself. The new names drop the misleading qualifier. &lt;code&gt;Dispatcher&lt;/code&gt; becomes &lt;code&gt;SyncScheduler&lt;/code&gt; — a more accurate description of what it actually does.&lt;/p&gt;

&lt;table&gt;&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deprecated&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;ImmediateAsyncDispatcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ImmediateDispatcher&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AfterCommitAsyncDispatcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AfterCommitDispatcher&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Dispatcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SyncScheduler&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 id="aggregateroot"&gt;AggregateRoot&lt;/h3&gt;&lt;h4 id="_code_apply____code__method_convention"&gt;&lt;code&gt;apply_*&lt;/code&gt; method convention&lt;/h4&gt;
&lt;p&gt;The old convention mapped event handlers by method name — &lt;code&gt;apply_order_placed&lt;/code&gt; would handle &lt;code&gt;OrderPlaced&lt;/code&gt;. The problem, which we even documented at the time: you can&amp;rsquo;t grep for usages of the event class. The &lt;code&gt;on&lt;/code&gt; DSL references the event class explicitly.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# deprecated&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;AggregateRoot&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_order_placed&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="vi"&gt;@status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:placed&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# use instead&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;AggregateRoot&lt;/span&gt;

  &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="no"&gt;OrderPlaced&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;@status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:placed&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;h4 id="_code_aggregateroot__configuration__code_____code_default_event_store__code_"&gt;&lt;code&gt;AggregateRoot::Configuration&lt;/code&gt; / &lt;code&gt;default_event_store&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Global state with a hidden dependency on the event store. Makes testing harder, makes the dependency invisible at the call site. Pass the event store explicitly to &lt;code&gt;Repository.new&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;# deprecated&lt;/span&gt;
&lt;span class="no"&gt;AggregateRoot&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Configuration&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="nf"&gt;tap&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;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_event_store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event_store&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# use instead&lt;/span&gt;
&lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AggregateRoot&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Repository&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;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event_store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="postgresql__code_valid_at__code__index"&gt;PostgreSQL &lt;code&gt;valid_at&lt;/code&gt; index&lt;/h2&gt;
&lt;p&gt;If you use bi-temporal queries (&lt;code&gt;as_of&lt;/code&gt;), add this index.&lt;/p&gt;

&lt;p&gt;PostgreSQL can&amp;rsquo;t use a regular column index for an expression in ORDER BY — it needs a dedicated functional index. Without it, &lt;code&gt;as_of&lt;/code&gt; queries fall back to a sequential scan. On a table with ~100k events that&amp;rsquo;s ~6 seconds. On tables with millions of events, even small result sets take 800ms–1600ms. The mechanics are covered in detail in &lt;a href="https://blog.arkency.com/how-to-add-index-to-big-table-of-your-rails-app/"&gt;How to add index to a big table of your Rails app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;New installations get a functional index on &lt;code&gt;COALESCE(valid_at, created_at)&lt;/code&gt; automatically. Existing installations:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bin/rails generate rails_event_store_active_record:migration_for_valid_at_index
bin/rails db:migrate
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The generator in 2.19.0 used a plain &lt;code&gt;CREATE INDEX&lt;/code&gt; — which locks the table for the duration of the build. We caught it and shipped 2.19.1 the next day with &lt;code&gt;algorithm: :concurrently&lt;/code&gt; and &lt;code&gt;disable_ddl_transaction!&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;PostgreSQL only — MySQL and SQLite use different syntax for expression indexes.&lt;/p&gt;
&lt;h2 id="under_the_hood"&gt;Under the hood&lt;/h2&gt;
&lt;p&gt;The CI matrix now covers &lt;strong&gt;Ruby 4.0&lt;/strong&gt;, &lt;strong&gt;Rails 8.1&lt;/strong&gt;, &lt;strong&gt;Redis 8&lt;/strong&gt;, &lt;strong&gt;PostgreSQL 18&lt;/strong&gt;, and &lt;strong&gt;MySQL 9.7&lt;/strong&gt;. We&amp;rsquo;ve dropped EOL versions: Ruby 3.2 (EOL March 2026), old Rails and ActiveRecord versions, PostgreSQL 13, MySQL 8.0.&lt;/p&gt;

&lt;p&gt;The test suite previously used multiple per-version dummy Rails apps. We&amp;rsquo;ve consolidated these into a single app driven by different Gemfiles across the CI matrix.&lt;/p&gt;

&lt;p&gt;Mutation coverage gaps have been closed — some after the tag cut.&lt;/p&gt;
&lt;h2 id="upgrading"&gt;Upgrading&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gem 'rails_event_store', '~&amp;gt; 2.19'
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Full release notes: &lt;a href="https://github.com/RailsEventStore/rails_event_store/releases/tag/v2.19.0"&gt;v2.19.0&lt;/a&gt;, &lt;a href="https://github.com/RailsEventStore/rails_event_store/releases/tag/v2.19.1"&gt;v2.19.1&lt;/a&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-04-24:/the-rails-way-in-2026/</id>
    <title type="html">The Rails Way in 2026</title>
    <published>2026-04-24T10:00:00Z</published>
    <updated>2026-04-24T10:00:00Z</updated>
    <author>
      <name>Andrzej Krzywda</name>
      <uri>https://blog.arkency.com/authors/andrzej-krzywda/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/the-rails-way-in-2026/" type="text/html"/>
    <content type="html">&lt;h1 id="the_rails_way_in_2026"&gt;The Rails Way in 2026&lt;/h1&gt;
&lt;p&gt;We had an interesting discussion at the Arkency weekly call today. The topic was how to define &amp;ldquo;the Rails Way&amp;rdquo; in 2026. The discussion branched in many directions, but I want to capture the result here.&lt;/p&gt;

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

&lt;p&gt;We get to see a lot of Rails repositories. There&amp;rsquo;s a pattern that shows up so consistently that I think it now deserves to be called &amp;ldquo;the Rails Way&amp;rdquo; of 2026:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fat model with a callback. The callback triggers a service object. The service object is executed as a background job.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That&amp;rsquo;s the clue of this post. If you zoom out across the Rails applications written or maintained in 2026, that&amp;rsquo;s the shape.&lt;/p&gt;
&lt;h2 id="the_disclaimer"&gt;The disclaimer&lt;/h2&gt;
&lt;p&gt;A word about where we&amp;rsquo;re coming from. Arkency is a company that often gets called in to fix existing large Rails applications. We help with performance, we help with improving the velocity, we also introduce domain-driven design and event-driven architecture where it makes sense. So our view is biased - we tend to see the apps that have grown past the point where the default Rails shape still fits.&lt;/p&gt;

&lt;p&gt;That bias is also what makes the pattern above so visible to us. We see it again and again.&lt;/p&gt;
&lt;h2 id="processes_and_workflows_inside_active_record"&gt;Processes and workflows inside Active Record&lt;/h2&gt;
&lt;p&gt;Part of the weekly discussion was specifically about how processes or workflows are implemented in Rails applications nowadays.&lt;/p&gt;

&lt;p&gt;The conclusion: they are implemented as part of the existing Active Record.&lt;/p&gt;

&lt;p&gt;Active Record is not only the state of an entity. It&amp;rsquo;s very often the state of a process. Sometimes it&amp;rsquo;s a shared state, because we&amp;rsquo;re talking about multiple entities collaborating together via &lt;code&gt;has_many&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you look at the columns of a typical Active Record table, you can see both kinds of state mixed together. Some columns describe the entity — often as enums:&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;Order&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="n"&gt;enum&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;pending: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;confirmed: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;paid: &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;shipped: &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;delivered: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;cancelled: &lt;/span&gt;&lt;span class="mi"&gt;5&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;That looks like entity state. But sit with it for a moment - &lt;code&gt;pending → confirmed → paid → shipped → delivered&lt;/code&gt; is not really the state of a thing. It&amp;rsquo;s the state of a process the thing is going through.&lt;/p&gt;

&lt;p&gt;And next to these enum columns, you&amp;rsquo;ll almost always find columns that are openly about the process:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# columns on the same orders table&lt;/span&gt;
&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:last_reminder_email_sent_at&lt;/span&gt;
&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:payment_retry_scheduled_at&lt;/span&gt;
&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;  &lt;span class="ss"&gt;:failed_payment_attempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:confirmation_email_sent_at&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;last_reminder_email_sent_at&lt;/code&gt; is not a property of the order. It&amp;rsquo;s a checkpoint in a workflow, &amp;ldquo;the dunning process has progressed this far.&amp;rdquo; The same goes for retry counters, &amp;ldquo;sent_at&amp;rdquo; timestamps, and &amp;ldquo;scheduled_at&amp;rdquo; fields. They&amp;rsquo;re persisted process state, living on the entity table because there&amp;rsquo;s nowhere else for them to live.&lt;/p&gt;

&lt;p&gt;So the Active Record row ends up being a blend: enum columns that look like entity state but actually track a workflow, plus auxiliary columns that openly admit they&amp;rsquo;re tracking a process.&lt;/p&gt;

&lt;p&gt;A single Active Record class ends up carrying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the data of the entity (the original purpose),&lt;/li&gt;
&lt;li&gt;the current step of a workflow the entity is going through,&lt;/li&gt;
&lt;li&gt;callbacks that advance that workflow,&lt;/li&gt;
&lt;li&gt;service objects triggered from those callbacks,&lt;/li&gt;
&lt;li&gt;background jobs scheduled from those service objects,&lt;/li&gt;
&lt;li&gt;and associations to other records that are part of the same process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The process is not an object. The workflow is not an object. They live as emergent behavior across a model, its callbacks, its associations, and the jobs they enqueue.&lt;/p&gt;

&lt;p&gt;This isn&amp;rsquo;t a new observation. Robert wrote about the same accumulation pattern years ago in &lt;a href="https://blog.arkency.com/2017/03/why-your-classes-eventually-reach-50-columns-and-hundreds-of-methods/"&gt;Why classes eventually reach 50 columns and hundreds of methods&lt;/a&gt; — framed there through bounded contexts. What&amp;rsquo;s different in 2026 is that the accumulated responsibilities are now explicitly &lt;em&gt;process&lt;/em&gt; responsibilities.&lt;/p&gt;
&lt;h2 id="one_more_piece__concerns"&gt;One more piece: concerns&lt;/h2&gt;
&lt;p&gt;There&amp;rsquo;s one more part of the Rails Way that deserves a mention — concerns.&lt;/p&gt;

&lt;p&gt;We don&amp;rsquo;t see them as often as we see the fat-model-plus-callback-plus-background-job pattern in the codebases we get called into. But concerns are definitely a significant part of the Rails Way too. Maybe they are more of the &lt;em&gt;official&lt;/em&gt; Rails Way than the Rails Way we see in the wild.&lt;/p&gt;

&lt;p&gt;A good example is the Fizzy codebase from 37signals. We recorded a walkthrough of it on our YouTube channel: &lt;a href="https://www.youtube.com/watch?v=-L6fjY3HlBI"&gt;Arkency reviews Fizzy, part 1&lt;/a&gt;. Fizzy leans on concerns to structure behavior across models. It&amp;rsquo;s worth watching to see the pattern applied intentionally rather than as accidental accumulation.&lt;/p&gt;

&lt;p&gt;So the full picture of the Rails Way in 2026 is probably: fat model, callback, service object, background job, process state living on Active Record and, where teams lean closer to the official Rails style, concerns as the way to organize all of that.&lt;/p&gt;
&lt;h2 id="why_this_matters"&gt;Why this matters&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m not using this post to argue against the pattern. I&amp;rsquo;m using it to name it. If we can&amp;rsquo;t describe what the Rails Way looks like today, we can&amp;rsquo;t have an honest conversation about when it serves us and when it doesn&amp;rsquo;t.&lt;/p&gt;

&lt;p&gt;So: fat model, callback, service object, background job and processes that live inside Active Record rather than beside it.&lt;/p&gt;

&lt;p&gt;That&amp;rsquo;s the shape of Rails in 2026.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-03-31:/rails-apps-have-layers-but-no-modules/</id>
    <title type="html">Rails apps have layers but no modules</title>
    <published>2026-03-31T11:00:00Z</published>
    <updated>2026-03-31T11:00:00Z</updated>
    <author>
      <name>Andrzej Krzywda</name>
      <uri>https://blog.arkency.com/authors/andrzej-krzywda/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/rails-apps-have-layers-but-no-modules/" type="text/html"/>
    <content type="html">&lt;h1 id="rails_apps_have_layers_but_no_modules"&gt;Rails apps have layers but no modules&lt;/h1&gt;
&lt;p&gt;You can have 200 models and zero modules. That&amp;rsquo;s the problem with typical Rails conventions. Rails supports layers - models, views, controllers. &lt;strong&gt;But layers are not modules.&lt;/strong&gt; Within one layer - especially models - usually all is mixed together. There are no boundaries.&lt;/p&gt;

&lt;!-- more --&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;line_items&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Such code is not so uncommon. It crosses &lt;strong&gt;4 business boundaries&lt;/strong&gt;. In just 1 line of code. All thanks to associations.&lt;/p&gt;
&lt;h2 id="the_problem_with_associations"&gt;The problem with associations&lt;/h2&gt;
&lt;p&gt;One of the first thing we teach in Rails is associations.&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;Order&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="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:user&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It&amp;rsquo;s very readable, feels right. Allows us to call it like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then we have the User class:&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;User&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="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:orders&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:invoices&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 Invoice class:&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;Invoice&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="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:order&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt;   &lt;span class="ss"&gt;:line_items&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 this is how we allow the original code:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;line_items&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This is how we boil the frog. One step at a time. One column at a time. One association at a time. The result? A User class with &lt;strong&gt;100 columns&lt;/strong&gt; in the database.&lt;/p&gt;
&lt;h2 id="dry_and_god_models"&gt;DRY and god models&lt;/h2&gt;
&lt;p&gt;There is a misconception about DRY - Don&amp;rsquo;t Repeat Yourself. We have an existing User class. It feels right to just add things there. &lt;strong&gt;No one was ever fired for adding a new column to the users table.&lt;/strong&gt; It feels like the User class is the right abstraction for DRY. Yet, it always ends as the god model.&lt;/p&gt;
&lt;h2 id="service_objects_don__39_t_help_with_modularisation"&gt;Service Objects don&amp;rsquo;t help with modularisation&lt;/h2&gt;
&lt;p&gt;Many Rails teams believe that Service Objects are the solution. They are, but to a different problem.&lt;/p&gt;

&lt;p&gt;Service objects help us when our controllers become too big. They are called from the controllers and they are the ones orchestrating ActiveRecord models. Often they handle transactions too.&lt;/p&gt;

&lt;p&gt;What is good about them? They are creating a boundary between the HTTP layer (controllers) and the domain layer. They also are a good solution to the transaction boundary.&lt;/p&gt;

&lt;p&gt;Service objects are a new layer. We could now call it MVCS. Model View Controller Service. It&amp;rsquo;s not bad. It does help with unit testing - it&amp;rsquo;s easier to unit test a service object than a controller action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service objects do nothing about modularisation.&lt;/strong&gt; They don&amp;rsquo;t create new boundaries. They don&amp;rsquo;t help with composing modules. Service objects are just &lt;strong&gt;another horizontal slice&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id="microservices"&gt;Microservices&lt;/h2&gt;
&lt;p&gt;It&amp;rsquo;s usually around this phase in the architecture - MVCS - when a decision is made. We will go microservices.&lt;/p&gt;

&lt;p&gt;Sometimes it comes from the team itself - what can be a stronger boundary than a network? The team hopes it will enforce a better design. Microservices bring the hope of starting fresh — new language, new design, better boundaries. But the boundaries &lt;strong&gt;still aren&amp;rsquo;t modules&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Are microservices helping with the modularisation? Nope. They are just yet another horizontal layer. This time we add a layer behind a network call. We no longer have transactions, it&amp;rsquo;s harder to run tests, the build takes longer. All for the benefit of having 3 new Go microservices and adding new layers of serialisation/deserialisation. &lt;strong&gt;More layers, less performance, but still no modules.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="a_bitter_conclusion"&gt;A bitter conclusion&lt;/h2&gt;
&lt;p&gt;Rails makes it easy to add code. It doesn&amp;rsquo;t make it easy to &lt;strong&gt;isolate it&lt;/strong&gt;. 200 models. Five layers. Zero modules. That&amp;rsquo;s the default.&lt;/p&gt;

&lt;p&gt;In 1972, Parnas wrote that a module hides a design decision from the rest of the system. Fifty years later, Rails apps hide nothing. What does your User class hide?&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-02-20:/getting-nondeterministic-agent-into-deterministic-guardrails/</id>
    <title type="html">Getting nondeterministic agent into deterministic guardrails</title>
    <published>2026-02-20T11:20:24Z</published>
    <updated>2026-02-20T11:20:24Z</updated>
    <author>
      <name>Łukasz Reszke</name>
      <uri>https://blog.arkency.com/authors/lukasz-reszke/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/getting-nondeterministic-agent-into-deterministic-guardrails/" type="text/html"/>
    <content type="html">&lt;h1 id="getting_nondeterministic_agent_into_deterministic_guardrails"&gt;Getting nondeterministic agent into deterministic guardrails&lt;/h1&gt;
&lt;p&gt;AI agents don&amp;rsquo;t reliably follow your instructions. Here&amp;rsquo;s how I made it hurt less.&lt;/p&gt;

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

&lt;p&gt;My context:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I currently work on a 12-year-old Rails legacy code base&lt;/li&gt;
&lt;li&gt;The code base is undergoing modernization. Some of the large Active Record classes have been split into smaller ones, each into its own bounded context. Events are becoming a first-class citizens in the code. We also pay close attention to keep direction of dependencies as designed by context maps.&lt;/li&gt;
&lt;li&gt;The client has a GitHub Copilot subscription. I mostly use Sonnet and Opus models.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the_basic_setup"&gt;The basic setup&lt;/h2&gt;
&lt;p&gt;Initially I started with the basics. I was curious where it would get us. There&amp;rsquo;s an AGENTS.md file with general rules to follow. Besides the AGENTS.md file I&amp;rsquo;ve added a few skills.
The goal of the skills is to tell the agent about how it should write code. I am a big fan of &lt;a href="https://blog.arkency.com/test-which-reminded-me-why-i-dont-really-like-rspec/"&gt;Szymon&amp;rsquo;s way of using RSpec&lt;/a&gt;. So I put that into a skill. I also developed a few skills that tell the agent how I want it to deal with event sourcing, ddd technical patterns, hotwire, backfilling data (especially events) and mutation testing. The mutation skill is quite essential because without it the agent goes bananas and tries to achieve 100% of mutation coverage with hacking. &lt;/p&gt;

&lt;p&gt;An example of hacking is calling &lt;code&gt;send(:method)&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;I don&amp;rsquo;t want to have such tests. Trying to achieve mutation coverage in such a way indicates that perhaps the code should be removed because it&amp;rsquo;s just unnecessary noise.&lt;/p&gt;

&lt;p&gt;So now the question is, is that enough?&lt;/p&gt;
&lt;h2 id="it__39_s_pretty_good_but_can_be_better___tackling_non_determinism"&gt;It&amp;rsquo;s pretty good but can be better – tackling non-determinism&lt;/h2&gt;
&lt;p&gt;More than once (a day) I&amp;rsquo;ve experienced my agent to go off-rails and ignore my instructions. It doesn&amp;rsquo;t respect what I&amp;rsquo;ve specified in AGENTS.md and/or skills.&lt;/p&gt;

&lt;p&gt;It often happens when I am asking it to introduce a very similar-yet-a-little-bit-different command and handler for a specific business use case.&lt;/p&gt;

&lt;p&gt;Changes to the production code are going very well. This is especially true if the goal is to replicate well-structured code. However, once it gets to the &amp;ldquo;write the tests&amp;rdquo; part, it switches to commodity mode and most likely uses RSpec in the most popular way, which I don&amp;rsquo;t like. This is a large part of the existing codebase. If it doesn&amp;rsquo;t fail on writing tests the way I want it, it usually doesn&amp;rsquo;t run mutation testing, even though I expect the coverage not to drop below a certain point and the mutants to be eliminated. They should be killed properly.
Using the &lt;code&gt;send&lt;/code&gt; method is no bueno.&lt;/p&gt;
&lt;h3 id="dealing_with_non_determinism"&gt;Dealing with non-determinism&lt;/h3&gt;
&lt;p&gt;So we&amp;rsquo;re not able to change whether the agent will respect AGENTS.md and skills all the time. At least not yet. Maybe never. So we have to deal with it differently.&lt;/p&gt;

&lt;p&gt;What I am currently testing is to have guardrails aka dev workflows. The idea is to run tools that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Will make me focus less on code structure, incorrect formatting, etc&lt;/li&gt;
&lt;li&gt;Make sure tests for changed files are run&lt;/li&gt;
&lt;li&gt;Make sure mutation tests are run&lt;/li&gt;
&lt;li&gt;And, last but not least, make sure that the boundaries within bounded contexts are not violated. I noticed that the agent, just like humans, loves to take shortcuts to achieve a goal. The difference is that I never tell the agent we&amp;rsquo;re under a strict deadline. So I&amp;rsquo;m not sure where this choice is coming from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow is Ruby code that is wired to a &lt;code&gt;/verify&lt;/code&gt; custom command. The command runs bash with &lt;code&gt;ruby -r ./lib/dev_workflow.rb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;dev_workflow.rb&lt;/code&gt; orchestrates the full pipeline. Looking at its requires tells you everything about what it runs:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/step_result'&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/result'&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/changed_files'&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/steps/base'&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/steps/rubocop_step'&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/steps/rspec_step'&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/steps/mutant_step'&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/steps/eslint_step'&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/steps/jest_step'&lt;/span&gt;
&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s1"&gt;'dev_workflow/verify_build'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Each step follows the same pattern: check if relevant files changed, run the tool, return a structured result. Here&amp;rsquo;s the mutation testing step as an example — the one that matters most given the problems I described earlier:&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;MutantStep&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Base&lt;/span&gt;
  &lt;span class="no"&gt;ALLOWED_NAMESPACES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sx"&gt;%w[CRM Ordering Billing]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;
    &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;changed_files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any_ruby?&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;StepResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;skip_reason: &lt;/span&gt;&lt;span class="s1"&gt;'no ruby files changed'&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;subjects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mutation_subjects&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;subjects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;StepResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;skip_reason: &lt;/span&gt;&lt;span class="s1"&gt;'no mutant-eligible files changed'&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;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;measure_duration&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;run_mutant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subjects&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;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt;
      &lt;span class="no"&gt;StepResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;duration_seconds: &lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;files_checked: &lt;/span&gt;&lt;span class="n"&gt;subjects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parse_mutant_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="no"&gt;StepResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;duration_seconds: &lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;files_checked: &lt;/span&gt;&lt;span class="n"&gt;subjects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;errors: &lt;/span&gt;&lt;span class="n"&gt;errors&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;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_mutant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subjects&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;subject_args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subjects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s2"&gt;"'&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'"&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;run_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"bundle exec mutant run --since HEAD &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;subject_args&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&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="nf"&gt;mutation_subjects&lt;/span&gt;
    &lt;span class="n"&gt;changed_files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ruby_files&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_with?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'spec/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter_map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;file_to_subject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;eligible_namespace?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniq&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;The key detail is &lt;code&gt;StepResult&lt;/code&gt;. Each step returns either &lt;code&gt;.skipped&lt;/code&gt;, &lt;code&gt;.success&lt;/code&gt;, or &lt;code&gt;.failure&lt;/code&gt; with structured data. This is what the agent reads to understand what went wrong and what to fix.&lt;/p&gt;

&lt;p&gt;Last but not least, to make sure that the non-deterministic agent won&amp;rsquo;t ignore my desire to run this command by itself, I attached it to a git pre-commit hook:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env ruby&lt;/span&gt;

&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"../lib/dev_workflow"&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DevWorkflow&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VerifyBuild&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;staged_only: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_json&lt;/span&gt;

&lt;span class="nb"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And at this point, at least calling the verify method is deterministic. So the agent gets feedback, fixes whatever is reported by the tool, reruns the verification and then it&amp;rsquo;s able to commit the changes.&lt;/p&gt;
&lt;h2 id="reviewing_changes"&gt;Reviewing changes&lt;/h2&gt;
&lt;p&gt;Besides AGENTS.md, SKILLS.md and the workflow I described above, I still review the code. I focus on tests, architecture and security parts.
I do take full ownership of the code that I ship. I don&amp;rsquo;t trust the AI enough to cut the leash. And my conclusion from working with it in a legacy codebase is currently that it will not change that fast (for me).&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>tag:blog.arkency.com,2026-02-05:/the-timezone-bug-that-hid-in-plain-sight-for-months/</id>
    <title type="html">The timezone bug that hid in plain sight for months</title>
    <published>2026-02-05T12:02:35Z</published>
    <updated>2026-02-05T12:02:35Z</updated>
    <author>
      <name>Szymon Fiedler</name>
      <uri>https://blog.arkency.com/authors/szymon-fiedler/</uri>
    </author>
    <link rel="alternate" href="https://blog.arkency.com/the-timezone-bug-that-hid-in-plain-sight-for-months/" type="text/html"/>
    <content type="html">&lt;h1 id="the_timezone_bug_that_hid_in_plain_sight_for_months"&gt;The timezone bug that hid in plain sight for months&lt;/h1&gt;
&lt;p&gt;We recently fixed a bug in a financial platform&amp;rsquo;s data sync that had been silently causing inconsistencies for months. The bug was elegant in its simplicity: checking DST status for &amp;ldquo;now&amp;rdquo; when converting historical dates.&lt;/p&gt;

&lt;!-- more --&gt;
&lt;h2 id="the_broken_code"&gt;The broken code&lt;/h2&gt;
&lt;p&gt;I found this while debugging a different sync issue — the real bug turned out to be hiding in a helper method I wasn&amp;rsquo;t even looking at.&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="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date_to_utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;offset&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;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;in_time_zone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TIMEZONE_MAP&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="n"&gt;timezone_key&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;formatted_offset&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;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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:to_i&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;utc&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Looks reasonable, right? Get the timezone offset, create a &lt;code&gt;Time&lt;/code&gt; object, convert to UTC.&lt;/p&gt;

&lt;p&gt;The problem: &lt;code&gt;Time.now.in_time_zone().formatted_offset&lt;/code&gt; gets the offset for &lt;strong&gt;right now&lt;/strong&gt;, then applies it to any date being converted.&lt;/p&gt;
&lt;h2 id="why_this_breaks"&gt;Why this breaks&lt;/h2&gt;
&lt;p&gt;Run this in December (EST, UTC-5):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;date_to_utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Date&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="mi"&gt;2023&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;:eastern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Gets -05:00 offset, but June 20 should be EDT (-04:00)&lt;/span&gt;
&lt;span class="c1"&gt;# Result: off by one hour&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Run the same code in June (EDT, UTC-4):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;date_to_utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Date&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="mi"&gt;2023&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;:eastern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Gets -04:00 offset, correct for June&lt;/span&gt;
&lt;span class="c1"&gt;# Result: works fine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Same input, different output depending on when you run it. Your tests pass in summer, fail in winter. Data syncs would occasionally miss records or pull wrong date ranges, depending on DST periods.&lt;/p&gt;
&lt;h2 id="the_fix"&gt;The fix&lt;/h2&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="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date_to_utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;tz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TimeZone&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;TIMEZONE_MAP&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="n"&gt;timezone_key&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
  &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;utc&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;ActiveSupport::TimeZone#local&lt;/code&gt; handles DST correctly for the specific date being converted. June dates always get EDT, January dates always get EST, regardless of when the code runs.&lt;/p&gt;
&lt;h2 id="the_test_that_exposed_it"&gt;The test that exposed it&lt;/h2&gt;
&lt;p&gt;Before touching the implementation, I wrote a test to confirm my suspicion — and it failed immediately.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s1"&gt;'produces consistent results regardless of system timezone'&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Date&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="mi"&gt;2023&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;expected&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;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2023&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="sx"&gt;%w[UTC Asia/Tokyo America/Los_Angeles]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&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;tz&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;use_zone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date_to_utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:eastern&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&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;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This test runs the same conversion in UTC, Tokyo, and LA timezones. The old implementation would produce different results depending on system timezone and time of year.&lt;/p&gt;
&lt;h2 id="impact"&gt;Impact&lt;/h2&gt;
&lt;p&gt;We caught this before it caused visible production issues, but the potential impact for a financial data integration was significant: off-by-one-hour shifts during DST transitions could cause missed records in date-range queries and validation mismatches between systems.&lt;/p&gt;
&lt;h2 id="lessons"&gt;Lessons&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Never use &lt;code&gt;Time.now&lt;/code&gt; for calculations on other dates. If you need timezone info for a specific date, use that date.&lt;/li&gt;
&lt;li&gt;Test with explicit timezone manipulation. Don&amp;rsquo;t rely on your system&amp;rsquo;s timezone matching production.&lt;/li&gt;
&lt;li&gt;DST transitions are sneaky. A bug that manifests only during certain months can survive code review and testing.&lt;/li&gt;
&lt;li&gt;Know your tools: &lt;a href="https://api.rubyonrails.org/classes/ActiveSupport/TimeZone.html"&gt;&lt;code&gt;ActiveSupport::TimeZone&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
</content>
  </entry>
</feed>

