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.