Ruby DelegateClass

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 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.