Fri, 12 Jan 07

Encapsulation in Active Record objects

I’ve been meaning to finish a post on this subject for ages. As I probably won’t finish it in its current incarnation, I thought I’d at least get something up here.

Active Record associations are brilliant for quickly putting together an object graph. They’re not so brilliant for testing, or keeping the code particularly tidy. They quickly lead to (and even encourage) train-wreck expressions in your code. Diving straight into an example:

class Person < ActiveRecord::Base
  has_many :hobbies
end
class Hobby < ActiveRecord::Base
  belongs_to :person
end

Because the hobbies object is publicly accessible from a person object, it allows us to reach in and touch things we shouldn’t.

cycling = Hobby.new

# We shouldn't manipulate the hobbies object directly...
chris = Person.new
chris.hobbies.add(cycling) # Uh oh - touching things we shouldn't

# ...rather, it'd be better to add a wrapper method for the functionality we actually need
class Person < ActiveRecord::Base
  def add_hobby(hobby)
    hobbies.add(hobby)
  end
end
chris = Person.new
chris.add_hobby(cycling) # Ahh, that's much better

The revised Person class above follows the law of demeter. As well as making the code look a little neater (no train-wrecks to see here), it makes it easier to test. To illustrate the testing point, I’m going to extend the association in this example.

# We end up with a large and not very clear test
class Person < ActiveRecord::Base
  has_many :hobbies do
    def find_favourite
      find(:first, :conditions => ['favourite = true'])
    end
  end
end

class MyContrivedTest < Test::Unit::TestCase
  def test_should_find_my_favourite_hobby
    hobbies = mock
    hobbies.expects(:find_favourite)
    person = stub(:hobbies => hobbies)
    hobby_finder = HobbyFinder.new
    hobby_finder.find_favourite(person)
  end
end

# This way, we end up with a slightly cleaner looking class (imo) and a smaller, cleaner test
class Person < ActiveRecord::Base
  has_many :hobbies
  def find_my_favourite_hobby
    hobbies.find(:first, :conditions => ['favourite = true'])
  end
end

class MyContrivedTest < Test::Unit::TestCase
  def test_should_find_my_favourite_hobby
    person = mock
    person.expects(:find_my_favourite_hobby)
    hobby_finder = HobbyFinder.new
    hobby_finder.find_favourite(person)
  end
end

Interestingly enough, if we had test driven the development of the above code, we would almost certainly have ended up with something similar (interface-wise) to the example that has a wrapper around the association proxy.

So, with all of this in mind, I’ve hacked together a real quick plugin that adds a corresponding private method for each of the four (has_one, has_many, belongs_to and has_and_belongs_tomany) associations. Using the _private alternative will do exactly the same as the original method but it will make all of the dynamically created methods private. So. It’ll still be possible to write the wrapper around the association (as we did in the test above) but it won’t be possible to reach in and touch things that we shouldn’t.1

There is at least one problem (there may be many more I’m not aware of) with using the private association methods. If you have validation set-up for one of the associations (e.g. validatesassociated) it will fail. This is because validation happens from outside the instance of the object and therefore needs to access the association. Although I haven’t thought this through fully, I’m actually wondering if validation (in its current form) takes a bit of a back seat under this new world order. I think that one of the reasons validation is on the object itself is because there are many many ways to create the said object. However, using these private associations and disabling the persistence methods (create, save, update etc) on an associated object may allow us to have a single point of object creation and persistence. If we had that, then we wouldn’t need the Active Record validations anyway. Just a thought…

Just one final note. Dave Astels has a good article about encapsulation in rails, as referred to by Ben

1 Ok. That is, of course, a blatant lie – we can still access the private association – this is ruby remember. The important point is that we’ve made our intentions more explicit.