Happy Bear Software

Rethinking Rails Models

The ruby community is currently awash with talk of object oriented design patterns, single responsibilty, separation of concerns, inversion of control, demeters law and all sorts of other arcane and esoteric encantations from the world of Java that apparently make your code gooder, for some function of goodness.

Personally, I'm still stuck about five years in the past trying to keep my controllers skinny and my models manageable. Here's what I've discovered so far about writing non-trivial rails applications.

Where does the model get used?

In your rails web application, the primary location your model gets used is in your controllers. The prevailing wisdom is to keep your controllers quite 'skinny' and contain as little domain logic as possible.

This makes sense, but the controller has to do something with your model.

In controllers we want high level touch-points to our domain-model code. The more declarative these touch-points are, the better. We want as little umming and aahing as possible. "Tell don't ask" and all that.

I've seen two patterns:

1. Find a domain resource and call a method on it

class PersonController < ApplicationController
  def discombobulate
    @person = Person.find(params[:id])
    @person.confuse
  end
end

2. Call a method on a class or module

class BusinessController < ApplicationController
  def aww_yeah
    BagOMethods.business_time!(params)
  end
end

Real-life controller code is rarely this clean. In practice it usually involves dealing with response headers, authentication and a host of junk that isn't related to your model.

Nevertheless I think these two ways of interfacing with your model are a good start. The 'protocol' shared between the controller and the model in this way is quite small so the controller doesn't need to know much about the model if we decide to operate like this.

Organizing Your Model

A little clarification first. Models are not necessarily subclasses of ActiveRecord::Base. As far as I'm concerned, your 'model' in rails is anything that is logic in your application that isn't tied to generating a HTTP response. That includes arbitrary ruby classes and modules that happen to live in your app/models directory.

Even so, in the vast majority of applications, the first thing we do is generate some 'models' which results in subclasses of ActiveRecord::Base, so let's start there.

Convention over Configuration is a bare-faced lie

Take a look at this typical model class in rails:

class Person < ActiveRecord::Base
  belongs_to :account
  has_many :addresses
  has_many :friends, through: :friendships

  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :date_of_birth, presence: true
  validates :some_other_field do
    # some custom validation code
  end

  attr_accessible :first_name, :last_name, :date_of_birth, :some_other_field, :upvote_count

  scope :public,   ->    { where(registration_complete: true) }
  scope :confused, ->    { where(status: 'confused') }
  scope :enlightened, -> { where('my_models.status != ?', 'confused') }
end

That there ladies and gentlemen, is a config file.

Admittedly it kicks the crap out of what was there before (big xml files used to configure your favourite Java ORM). But it's still a config file. Or if you're feeling delicate, it's a config file with sensible defaults that happens to work with minimal options specified should you use the built in generators.

I don't think there's anything wrong with this, and sticking our fingers in our ears and chanting 'lalala convention over configuration' isn't particularly healthy. If anything, I'd like to keep it this way and plug other bits of domain logic into it.

And as a config file, it's great! The DSL for specifying relations, validations and everything else ActiveRecord gives you out of the box is magnificent, I'm a huge fan.

The next logical step however, is almost always to add some domain-logic methods to those model classes:

class Person < ActiveRecord::Base
  belongs_to :account
  has_many :addresses
  has_many :friends, through: :friendships

  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :date_of_birth, presence: true
  validates :some_other_field do
    # some custom validation code
  end

  attr_accessible :first_name, :last_name, :date_of_birth, :some_other_field, :upvote_count

  scope :public,   ->    { where(registration_complete: true) }
  scope :confused, ->    { where(status: 'confused') }
  scope :enlightened, -> { where('my_models.status != ?', 'confused') }

  def confuse
    self.status = 'confused'
  end

  def confuse!
    confuse
    save
  end

  def confused_friends
    friends.confused
  end

  def complex_operation
    if friends.some_complex_domain_condition?
      do_something_complicated
    else
      do_something_else
    end
  end

  def do_something_complicated
    SomeOtherClass.new(foo: 'bar', bar: 'baz', baz: 'foo').new.delegate_complication
  end

  def do_something_else
    3.times { clap_hands }
  end

  def clap_hands
    %w(clap)
  end

  # ... a few hundred more lines of this
end

Which I'm not that great a fan of.

What do you gain from tacking your domain model methods on the the bottom of your ActiveRecord::Base subclass?

And what sucks about it?

Instead, I much prefer the following sort of setup:

# app/models/person
class Person < ActiveRecord::Base
  include ConfusedPerson
  include ComplexPersonOperations

  belongs_to :account
  has_many :addresses
  has_many :friends, through: :friendships

  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :date_of_birth, presence: true
  validates :some_other_field do
    # some custom validation code
  end

  attr_accessible :first_name, :last_name, :date_of_birth, :some_other_field, :upvote_count

  scope :public,   ->    { where(registration_complete: true) }
  scope :confused, ->    { where(status: 'confused') }
  scope :enlightened, -> { where('my_models.status != ?', 'confused') }
end

# app/models/confused_person
module ConfusedPerson
  def confuse
    self.status = 'confused'
  end

  def confuse!
    confuse
    save
  end

  def confused_friends
    friends.confused
  end
end

# app/models/complex_person_operations
module ComplexPersonOperations
  def complex_operation
    if friends.some_complex_domain_condition?
      do_something_complicated
    else
      do_something_else
    end
  end

  def do_something_complicated
    SomeOtherClass.new(foo: 'bar', bar: 'baz', baz: 'foo').new.delegate_complication
  end

  def do_something_else
    3.times { clap_hands }
  end

  def clap_hands
    %w(clap)
  end
end

All we've done is pulled the methods out into modules. What do we gain from this?

And what does this cost us?

Another important benefit we get here (that doesn't quite fit into a bullet point) is that any modules that we delegate to can in turn delegate to a different set of domain-level classes and modules that perform arbitrary logic for you. In this way, the included modules act as a gateway between your actual domain-logic and your ActiveRecord::Base subclasses.

It's a given that if you start to build up an unmanageable number of modules in your app/models directory that you break out some namespaces and start putting them in relevant subdirectories.

More Complex Operations

Sometimes the operation you want to do isn't on an individual resource, or even directly on an ActiveRecord::Base subclass. Here's that second example again:

class BusinessController < ApplicationController
  def aww_yeah
    BagOMethods.business_time!(params)
  end
end

When there's no state to be managed, I don't see any harm in putting a bunch of methods on a module:

module BagOMethods
  def self.business_time!(parameters)
    a = Person.find(parameters[:a][:id])
    b = Person.find(parameters[:b][:id])
    a.do_some_business_with(b)
  end
end

This is a relatively simple way to keep your controller code concise and declarative. You're not coupling your controller to your model apart from at well-defined points and can therefore change your model implementation with no impact on the controller.

Related Links