Rails: The Sharp Parts. Queries, Read Models, and Batching | baweaver
baweaver
baweaver
Software Engineering
Simplicity is hard work. But there's a huge payoff. The person who has a genuinely simpler system is going to be able to affect the greatest change with the least work.<br>— Rich Hickey
ruby<br>June 14, 2026
Rails: The Sharp Parts. Queries, Read Models, and Batching
Last time we moved domain behavior out of callbacks and into commands with one door.
Aside : The patterns in this series come from my time working in large Rails monoliths (1M+ lines of code, 10+ years of history, hundreds to thousands of engineers). What I see from that vantage point may not be relevant yet to greenfield or early-stage apps. These problems show up at a certain scale and not before.
We did so because that one door controls who gets to define what it means to “write” to our data, rather than hoping that our consumers do not make up their own definitions (they will.) The problem we face is that anyone can call save or another mutation method from anywhere in the application. Every controller, job, rake task, and more is now an active liability in which you do not have control over your own data. A single command ingress is designed to take back that control and put it behind a single interface: Writes go through your door, and no one else’s, every other path is closed and all your rules live in one clear place.
Great, so we have a reasonable lock down on commands, but what about reads? As we reflected on in the earlier indexing article each distinct query has a distinct index that maps to it and optimizes it, so if you don’t happen to control the shapes of those queries and instead give complete and total access to your query interface to every single consumer how likely do you suppose it is that you end up with an optimized application? It won’t be, and trying to optimize external callers is a losing proposition.
Going back to our last post we had Seats::ReserveSeat.call returning Seat, an ActiveRecord object. The caller can very easily call update! on the object returned, or chain further queries off it, or read associations that fire new SELECTs on every access. The single door we spent time installing has a window cut into the wall beside it, and every reader who takes advantage of that fact makes that gap wider and wider until that door becomes more a polite suggestion than a solid gate. We need rules that cover both directions: Anything that crosses a pack boundary needs to be an inert shape with no behavior, and a live relation will never satisfy that requirement.
The Leak
Here is a reader that returns a relation:
def open_seats = Seat.where(reserved: false)
It reads, the name says read, and nothing about it looks dangerous. A caller picks it up and writes straight through it:
def the_leak<br>reserved_before = Seat.where(reserved: true).count<br>open_seats.update_all(reserved: true)<br>reserved_after = Seat.where(reserved: true).count<br>{reserved_before:, reserved_after:}<br>end<br># => {reserved_before: 0, reserved_after: 12}
A relation is a query the caller can keep extending, and update_all is one of the extensions, which makes the reader’s return value a writable handle to the seats table presented as a list.
A single record carries the same problem. The object you fetch to show a seat (Seat.find(1)) answers to update! as willingly as it answers to reserved, because it is the same object either way. And if that record belongs to something, reading the association bills the caller one query per row leading to N+1 problems.
Let’s use this small counter method to see what’s happening:
def count_statements_for<br>total = 0<br>sub = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|<br>next if event.payload[:name] == "SCHEMA"<br>total += 1<br>end<br>yield<br>ActiveSupport::Notifications.unsubscribe(sub)<br>total<br>end
And we can see what happens directly for that fanout:
def lazy_fanout<br>seats = Seat.order(:id).to_a # one query, before we start counting<br>count_statements_for { seats.map { |seat| seat.event.name } }<br>end<br># => 12 (twelve seats, twelve SELECTs)
That means we have a fanout happening in a consuming pack, all to get an attribute, that scales to the size of the collection and chances are you as the producer has no idea this is happening either.
Since the returned object can write, it will fire queries for every touched association, and it will expose every column, scope, and association the model carries while it’s at it. That means the entire surface area of your model has now inadvertently become the public API by which all other consumers will interface with your data, making any boundary effectively useless.
What a Value Is
ActiveRecord objects are live. What we need are inert objects that only expose the data we want them to have. Value objects are an answer to this, and Sorbet’s T::Struct is one compelling option.
Aside on Static Typing in Ruby : This has been a long-contentious subject in Ruby-land...