Ruby DelegateClass08 Nov 2015
Objects are a big deal in Ruby. A previous post about Ruby Objects can corroborate: Ruby Objects are pretty cool.
There are many ways to work with Ruby Objects. Standard inheritance, module inheritance, decorators, and composition are all commonly found in a codebase. One class in particular seems almost universal across applications: the
From social networks to online retails stores, web applications most always have a
user. Additionally, the
User class might interact with nearly all other classes. This can make the relationship and responsibilities of that
User awkward or unwieldy over time.
As an example, assume that an application “booksandreviews.com” exists. This application serves both authors and critics, each representing a
User types have different responsibilities and privileges.
Assuming a Rails application, the basic user model will look most likely like:
class User < ActiveRecord::Base end
One approach to extend this class and make
Critic classes is to use the Single Table Inheritance pattern. STI is a common pattern in Rails applications to share responsibilities between classes and store all records in the same place. This approach can work and might fit current needs just fine, but it might be beneficial to think outside the single table for a second.
After all, no one said a
User had to be either an
Author or a
Critic, what if they were both?
Delegators, Mount Up!
An alternative way to architect a system like the one described is by using delegation. Object delegation is a way of composing objects to achieve flexibility and maintain encapsulation.
Ruby provides a few ways to delegate objects. An interesting option that will nicely serve this application’s needs is
DelegateClass method accepts a class and returns a new class. The returned class takes the passed in class’s instance methods and defines delegating methods.
class Author < DelegateClass(User) end class Critic < DelegateClass(User) end
These classes both expect to be initialized with an instance of
User, then will delegate methods to the passed in
user by default. This allows each class to define their own logic for determining what an
Author or a
Critic has access to.
Initializing these classes is as simple as loading the
user then passing it in:
user = User.find(8) critic = Critic.new(user) critic.id # => 8
Review records exist in this same application, related to a user by a
user_id, both new classes can handle those resources independently.
class Author < DelegateClass(User) def written_books @written_books ||= Book.where(user_id: id) end def has_written_books? written_books.present? end def top_selling_book written_books.max_by(&:revenue) end end class Critic < DelegateClass(User) def written_reviews @written_reviews ||= Review.where(user_id: id) end def has_reviewed_book?(book_id) written_reviews.where(book_id: book_id).present? end def top_viewed_review written_reviews.max_by(&:views) end end
See how nice it is with the
User class responsible only for standard
User information? That leaves the
Critic classes available to house business logic the
User should not have to concern itself with.
User class can keep caring about things like
updated_at timestamps, important booleans like
is_a_confirmed_user?, and any other methods that indicate how a general
user interacts with the software. Also, it remains the sole responsibility of the
user to interact with the persistence layer. The
Critic classes will delegate instance methods like
update to the underlying
Adding Yet Another Type
Imagine some time passes and “booksandreviews.com”’s membership is popping off like gangbusters. Then one day, Tim from sales comes down and says: “We are literally making no money, we need to sell these books or something”.
Tim is asking for a new use case for the product, a new type of
User will be needed: the
Consumer. As before, a
Consumer could be an
Critic, both, or neither.
Had this application been built with STI, I would wager some spaghetti code would form trying, to shoehorn the same user into three roles.
However, with the
DelegateClass pattern we have used, it becomes trivial:
class Consumer < DelegateClass(User) def transactions @purchased_books ||= Transaction.where(user_id: id) end def purchaed_books transactions.map(&:book) end def needs_marketing_email? # Tim says we need to email people to "encourage" # them into buying things. transactions.length == 0 end end
Now this new class can be utilized to spam the users of “booksandreviews.com” until Tim is content.
An important note is that instances of
Consumer are not instances of
user = User.find(9) author = Author.new(user) author.is_a?(User) # => false
Keeping it Closed
DelegateClass approach keeps the
User class open to extension but closed for modification. Adhering to this principle becomes very beneficial if the backing of the
User model changes.
Maybe in the future, “booksandreviews.com” experiences astronomical growth, resulting in a huge service oriented architecture overhaul. Suddenly, a
UserService exists which communicates with an internal API. If that happens, our code is safe.
Consumer classes need not change:
user = UserService.find_user_by_id(10) consumer = Consumer.new(user) consumer.id # => 10
As previously stated, there are many ways to solve this particular problem. Arguments for module inheritance or single table inheritance could be made and might result in just as valid solutions.