mikey.bike • archive • other writings • about
Rails controllers have a trick to pass data to the view: all instance variables are copied over after an action method is executed. This is at odds with good Ruby object design and causes practical problems. It becomes awkward to refactor controller methods without exposing unwanted state to the view — a private method that sets an instance variable has side effects elsewhere in the code. Controllers have the strange task of mutating their inner state as their public behavior.
I’d have guessed that Object#instance_variable_get
and
#instance_variable_set
were private methods. Not so. Apparently it
is “public” API for you to mess with any Ruby object’s internals.
How many Ruby classes have you needed to test by examining their instance variables? I don’t think I’ve done this anywhere, except in controller tests, where it’s the norm.
It is true that the trick is aesthetically pleasing for simple controllers and for when you need to demo Rails. Take a standard Rails controller with one simple action:
class PostsController < ApplicationController
def show
@post = Post.find(params[:id])
end
end
And now, in the corresponding view, the @post
is available:
<h1>
<%= @post.title %>
</h1>
For simple controllers, you might think of the view as an extension of the controller. It has access to private internals. The template is rendered as if it were just another method on the controller.
But in the MVC pattern, views and controllers have different concerns. A controller generally manipulates model objects to interact with the database; a view should not. Controllers send email, spawn background jobs, handle validation failure, catch exceptions. Views do not.
Using ActiveRecord
objects in the view seems like a good thing to
avoid if possible. These are generally the most powerful objects in a
Rails app. They have an enormous API and many methods query the
database. One of our models at Freebird has 619 instance methods (not
counting those from Object
) — most of those are added by
ActiveRecord
. It’s probably a bad idea for a view, responsible for
producing HTML, to execute SQL queries.
Additionally, objects used in a view often need methods
specific to formatting content for the user. Adding these to
ActiveRecord
classes makes their large interface even wider.
At Freebird, we’re moving to using Presenter
objects that wrap
ActiveRecord
objects, exposing only the methods that the view needs
and adding view-specific logic. But we found it was a little awkward
to introduce them into standard controllers. Consider this code:
class PostController < ApplicationController
def show
= Post.find(params[:id])
post_model @post = PostPresenter.new(post_model)
end
end
Even in this simple show action, there is some awkwardness with
naming. @post
in the view is now a PostPresenter
— good, this is
what we want. Unfortunately, now the controller must distinguish
between model and presenter objects, and with the @post
name
reserved for the presenter, our model variables get an awkward name
like post_model
. We have to rename and rearrange our controller’s
instance variables because their names are used in the view; something
about this feels wrong.
With a few more actions, the situation gets worse:
class PostsController < ApplicationController
:require_post
before_action
def show; end
def edit; end
def update
if @post_model.update(params[:post].permit(:title, :body))
@post_model, notice: "Post updated."
redirect_to else
:edit
render end
end
private
def require_post
@post_model = Post.find(params[:id])
@post = PostPresenter.new(@post_model)
end
end
An alternative is to overwrite the @post
variable for the view,
e.g., @post = PostPresenter.new(@post)
. But this is error-prone,
confusing and — were we using Sorbet — a type error.
We’ve factored out common logic into require_post
, but in doing so,
our views now have access to the @post_model
instance variable. And
we now have two kinds of instance variables, one that is meant to be
passed and used in the view, and one that is not. This makes the
intention of the code harder to follow, especially in more complex
controllers.
Thankfully, this is easy to address. The public method used to
populate the view with instance variables is
AbstractController#view_assigns
. It builds a hash from all the
instance variables in the controller object. It is not a complicated
method:
# This method should return a hash with assigns.
# You can overwrite this configuration per controller.
def view_assigns
= _protected_ivars
protected_vars = instance_variables
variables
.reject! { |s| protected_vars.include? s }
variables.each_with_object({}) { |name, hash|
variables[name.slice(1, name.length)] = instance_variable_get(name)
hash}
end
Interestingly, in Merb, views were just methods on controllers, meaning the apparent sharing of state was structural. I think Rails got it right here.
Merb’s “proof that everything belongs in one class to begin with” was that it was more performant.
Controllers inherit from AbstractController
, so we can override
view_assigns
to do whatever we want. Let’s have it simply return a
hash that is set in a new method called present
:
module Presenters
def view_assigns
@_presenters || {}
end
def present(hsh)
@_presenters = hsh
end
end
Now we can include the Presenters
module in our controller, and our
instance variables will not be passed to the views. Instead, we
explicitly assign variables to the view with present
:
class PostsController < ApplicationController
include Presenters
:require_post
before_action
def show
post: PostPresenter.new(@post))
present(end
def edit
post: PostPresenter.new(@post))
present(end
def update
if @post.update(params[:post].permit(:title, :body))
@post, notice: "Post updated."
redirect_to else
post: PostPresenter.new(@post))
present(:edit
render end
end
private
def require_post
@post = Post.find(params[:id])
end
end
The awkwardness of controller instance variables is solved — they can simply be used to share instance state, as they are meant to do. We can refactor controller code without worrying what will end up in a view.
We can clean up this code a little more. If we assume that a
Presenter
is always instantiated in the same way — a
ModelNamePresenter
accepts a ModelName
object as a single
initialization parameter — we can instantiate the presenter using the
class of the object passed in:
def present(hsh)
@_presenters ||= {}
.each_with_object(@_presenters) do |(k, v), acc|
hsh[k] = "#{v.class}Presenter".constantize.new(v)
accend
end
Calling present(post: @post)
now inspects the class name of @post
,
finds the corresponding Presenter
class and instantiates a presenter
with the @post
object passed to the constructor. The final
controller looks like this:
class PostsController < ApplicationController
include Presenters
:require_post
before_action
def show
post: @post)
present(end
def edit
post: @post)
present(end
def update
if @post.update(params[:post].permit(:title, :body))
@post, notice: "Post updated."
redirect_to else
post: @post)
present(:edit
render end
end
private
def require_post
@post = Post.find(params[:id])
end
end
The present
method will fail with a uninitialized constant exception
if an appropriate Presenter
class is not found. In this way we
enforce some consistency in the view: instance variables must be
presenter objects.
We have split apart the shared scope between the controller and the
view, with the present
method providing the interface between
them. A developer working in controller code can be confident instance
variables incidentally used for refactoring actions won’t affect view
code. A front end developer knows exactly which variables were meant
for the view. If nothing else, there is a self-documenting nature to
present
that the standard Rails controller lacks.
Freebird has extracted a simple library for setting this up in Rails
controllers, as well as providing a base Presenter
class with some
conveniences. Check it out, it’s called Livery. It does a bit more
than the PostsController
example here, but the basic idea is the
same. For instance, our implementation of present
handles passing in
presenter objects directly, objects with module namespaces, and
collections.