Ruby DelegateClass
08 Nov 2015Objects 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 User class.
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. These 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 Author and 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. The 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.
Defining Author and Critic classes:
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
Business Logic
Assuming that Book and 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 Author and Critic classes available to house business logic the User should not have to concern itself with.
The 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 Author and Critic classes will delegate instance methods like save and update to the underlying user.
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 Author, 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 Author, Critic, and Consumer are not instances of User.
user = User.find(9)
author = Author.new(user)
author.is_a?(User)
# => false
Keeping it Closed
The 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.
The Author, Critic, and 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.