Slim down hefty Rails controllers AND models, using domain model events

If you've done much Rails coding, you've probably heard the guideline: “skinny controller, fat model”. But achieving this can be easier said than done. Especially when your controllers are bloated with HTTP-centric responsibilities, such as websocket notifications, that don't really seem to belong in your domain model objects either.

In this screencast and article, you'll learn a refactoring technique that puts your controllers on a diet, without shoehorning a bunch of web server-centric code into your database-backed model classes.


Start with a single controller action

 Let's take a look at a typical Rails controller action. This action is from a project management application.

In this action we're updating a task in a project. First, we make note of the current values of a few of the task's attributes, for later use. We then try to update the task with the given parameters. When this is successful, we take several actions:

  1. If the task's status has changed, e.g. from “in progress” to “complete”, we mail out notifications to interested users.
  2. If the task has been moved to another project, we push live updates out to users, using websockets or some other form of asynchronous browser notification.
  3. We then push out one or more task-related notifications, letting interested users know that the task has been created, updated, or deleted.
  4. If the person the task is assigned to has changed, we send out email notifications to let the new and previous assignees know about the change.

This clearly qualifies as a fat controller action. But when we try to put it on a diet, by moving logic into the model, we run into a problem: it turns out that all of activities this action performs are dependent on session-specific knowledge. Methods like #push_task and #mail_assignment need to know things like who the current user is, or what the browser's asynchronous socket ID is. We don't want to push that kind of knowledge down into domain model code.

Identify implicit domain lifecycle events

Looking over this action again, we realize something: hidden in all these conditionals is a series of domain-model lifecycle events, each with different concrete actions which trigger on those events.

  1. There are some actions to perform anytime the task is successfully updated.
  2. There are actions to take when the task has moved from one project to another.
  3. There are actions to perform when the task is newly created.
  4. There are actions that happen when the task's status has changed.
  5. There are actions for when the task has been reassigned.

Make the events observable

It's the controller's job to know things like the ID of the current user, and how to push out a notification to their browser. But it's really the model's job to know when events occur in its lifecycle.

With that in mind, we set out to give the Task model the ability to notify interested objects when these events occur. We give it a method called #add_listeners, which adds an interested object to an internal list.

We add another method, #notify_listeners, which loops through the listener list…

…and sends a message to each one, named for a specific event, such as :on_create or :on_status_changed.

Then we add an ActiveModel “around” callback which will call a method called #notify_on_save whenever a Task is saved.

Finally, we implement the #notify_on_save callback method.

The first part of the method makes various checks to determine what kind of save this is. That is, whether the task is being created or updated, whether it is moving from one project to another, and so on. We make heavy use of ActiveModel‘s “dirty attributes” features here, using methods like project_id_changed? to see if the project_id has been changed from its database value.

Then it yields to its caller. Because this is used as an “around” callback, this yield is the point at which the actual save occurs.

After the task is saved, this method proceeds to send various notifications to any listeners that have signed up. If the task is newly created, the listeners will receive the #on_create message:

If the status has changed they will receive #on_status_change, and so forth.

Some of these messages also have some extra arguments to go with them; for instance, in the case where the task is moved to a new project, the notification provides both the original project and the new project.

Make the controller observe the model

Now that we've made our model observable, (to use Gang of Four terminology), we turn our attention back to the #update controller action. We decide, in the interest of taking small steps, to simply make the controller itself a Task listener for now. So before anything else, we add self to the @task‘s list of listeners.

The original fat if-else-end becomes a slim if-else-end.

We then proceed down through the method, pulling code out into methods named for Task lifecycle events.

Such as when the task is newly created…

…where the task is moved to a new project…

…when the task's status has changed…

…when the task has been reassigned…

…and when the task is updated.

Extract dedicated listener classes

Once we satisfy ourselves that the controller still works the same way it did before this refactoring, we begin to tease these methods apart further, breaking them up into new “listener” classes that each correspond to a specific aspect of the controller's former responsibilities. For instance, here's a Task listener which only handles the browser push notifications, and not email notifications.

Wire up the listener classes to the model(s)

Back in the controller, instead of passing self to #add_listener, we can now add a series of listener objects, one concerned with pushing browser notifications, another with sending emails. Each of these objects encapsulates the details of how its particular mode of communication is implemented.

Aside: What about Rails Observers?

If you've been working with Rails for a while, you might be wondering how this approach differs from Rails 3-era Observers.

When you create a Rails ActiveRecord Observer, it is automatically wired into every invocation of every model action to which it applies. This results in “spooky action at a distance“, where invoking a model method causes code in other classes to be unexpectedly executed, often with surprising and unwanted results. Over the years Rails programmers have found that these kind of implicit observers cause so many problems that many project style guides have banned their use, and the library was eventually removed from Rails.

By contrast, the kind of observability we've added here is completely explicit and opt-in. We add our listeners at the beginning of the controller action we want them to observe. They stick around for the length of that one method invocation, and then they are gone. Anyone reading the controller code can see which listeners are added to which model objects, and can know to expect callbacks into those listeners.

An added bonus is that, since we create the observers in the controller action, we're able to explicitly pass the session-specific information and collaborator objects that the observers need as constructor arguments. Instead of having to sneak that information into the observers by less obvious means.

Conclusion: Skinny controller, model, and observers

We've now divided a fat controller into three distinct areas of knowledge:

  1. Information about the current session and request, including user ID and parameters. The controller takes responsibility for this.
  2. The events that may occur in a Task‘s lifetime. The Task model is now responsible for this knowledge.
  3. Who should be notified about various Task lifecycle events, and how they should be notified. Various medium-specific listener classes encapsulate this knowledge.

We've looked at this in the context of a Rails controller action, but it's really a technique that's applicable to any kind application, web-based or otherwise. Splitting logic into events and observers is a fundamental technique for untangling domain-model and user-interface responsibilities.

Thank you to OpusWorkspace for allowing me to use this real-world code example.

Happy hacking!

Get the source code

Want to see the full source code for this article, including original, midpoint, and final states? Click here to download!