I am building a real application in Hanami 3, and I am doing it with my Rails habits switched on rather than off. Not out of laziness: I want to find the places where twenty years of muscle memory stops working, because those are the places worth writing about. Anything I can read in the documentation does not need a post from me.

The first habit broke inside the first action I wrote.

The habit

The app is a tracker for security challenges, and it is public if you want to read along. The first page lists them. In Rails I would not think about this for a second:

def index
  @challenges = Challenge.all
end

Hanami puts one class per action rather than one controller with seven methods, so the shape is different, but my hands did the same thing anyway:

class Index < CtfTracker::Action
  include Deps["repos.challenge_repo"]

  def handle(request, response)
    @challenges = challenge_repo.all
  end
end

And the template read @challenges, because that is what templates do.

The error

I expected one of two outcomes. Either it would work, since an action is roughly a controller and instance variables are cheap, or the template would render an empty list because the variable did not carry across the boundary.

Neither:

FrozenError: can't modify frozen CtfTracker::Actions::Challenges::Index:
#<CtfTracker::Actions::Challenges::Index:0x00000001232d2138 [...]

I cut that short. The real message is 100309 bytes, because FrozenError inspects the object it refused to modify, and inspecting this one drags in the repo, the view, the router and most of the container standing behind them. The first line is the part that matters.

That is not a convention nudge. It is not a deprecation. The object refused to hold the value at all.

One object, shared

The message says what happened, so I checked the shape of it rather than assume. Resolve the action twice from the container and compare:

a = Hanami.app["actions.challenges.index"]
b = Hanami.app["actions.challenges.index"]

puts "same object across two resolutions: #{a.equal?(b)}"
puts "frozen?: #{a.frozen?}"
same object across two resolutions: true
frozen?: true

One instance, built once, frozen, and handed out to every request that arrives.

That include Deps["repos.challenge_repo"] line is part of the same story. Hanami resolves the repo and hands it to the action when the action is constructed, so by the time a request shows up, everything the action needs from the outside world is already wired in. There is nothing left for it to look up, and therefore nothing it needs to remember.

Put that next to Rails and the reason becomes obvious. A Rails controller is instantiated per request, which is exactly why @challenges is safe there: the object holding it is private to one request and thrown away after. Its lifetime is the request’s lifetime, so its state can be the request’s state.

A Hanami action has no such lifetime. It is a long-lived, shared object, and two requests hitting /challenges at the same time are calling methods on the same instance. If @challenges worked, it would be a data race with a plausible-looking API. So the framework closed that door and welded it shut. You cannot write the bug, which means you cannot ship it.

I like this more than a guideline saying “prefer not to”. A guideline is something a tired developer skips at six in the evening.

What goes in its place

Request-scoped state lives on the response, and the view says what it expects. The action becomes:

def handle(request, response)
  response[:challenges] = challenge_repo.all
end

The view declares the exposure:

class Index < CtfTracker::View
  expose :challenges
end

And the template uses a local rather than an ivar:

<% if challenges.empty? %>
  <p>No challenge yet</p>
<% else %>
  <ul>
    <% challenges.each do |challenge| %>
      <li><%= challenge.name %> (<%= challenge.category %>)</li>
    <% end %>
  </ul>
<% end %>

response[]= writes into the response’s exposures, which get merged into the view’s input when the paired view renders. The response is created inside the call, so it is per-request, which is what makes it a legitimate place for per-request data.

The app at this point is tagged 01-frozen-action, if you would rather read the three files together than in pieces.

Is it worth the extra line

Honestly, on this page, the Rails version was shorter and I understood it faster. That is a real cost and pretending otherwise would be silly.

What I get back is a contract. expose :challenges is the complete list of what the template is allowed to reach for. In a Rails app of any age, finding out what a template actually needs means grepping for every ivar the action might have set, plus whatever a before_action quietly added, plus whatever a helper pulls out of thin air. I have done that archaeology more times than I would like. Here the answer is one line at the top of the view, and if the template wants something new, somebody had to write it down.

There is a second effect, less visible. On the form page of the same app, I had to hand the template its validation errors, and having to name what I was handing over made me notice they arrived in two different shapes. So the reshaping went into the view, where it belongs. In Rails I would have reached into the ivar from the template and found out later, in production, on the one input nobody tested.

What I am watching next

The freeze is a small piece of a bigger pattern I keep bumping into: Hanami tends to answer “who owns this?” by making the wrong owner impossible rather than inadvisable. I have a growing list of these from building this app, and the next one is stranger. Asking Hanami for the model gets you four objects, and none of them is called the model.

Comments, questions, or just a reaction?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.