Schwern
Schwern

Reputation: 164679

Decorator pattern for model functionality in Rails 5

I'm developing a Rails 5 application following the "fat model / thin controller" pattern. As I'm adding things like logging and validation I'm finding my models are getting a bit too fat. For example, here's a sketch of what the method for subscribing to a list looks like...

class SubscriberList < ApplicationRecord
  # relationships and validations

  def subscribe!(args)
    log that a subscription is attempted

    begin
      do the subscription
    rescue errors
      log the failure and reason
      rethrow
    end

    log successful subscription
    log other details about the subscription

    SubscriptionValidationJob.perform_later( new_subscriber )

    return new_subscriber
  end
end

Its increasingly getting in the way that logging and validation are welded to the act of subscribing. I understand I should solve this by moving logging and validation into decorators probably using draper.

I don't have much experience with decorators. My main concern is bugs due to code using an undecorated model when it should be using the decorated model. Or vice versa. The interfaces are the same, the changes are side-effects, so it would be hard to detect.

I'd be tempted to use decorates_association and decorates_finders liberally to avoid this, but Draper documentation says to avoid this...

Decorators are supposed to behave very much like the models they decorate, and for that reason it is very tempting to just decorate your objects at the start of your controller action and then use the decorators throughout. Don't.

However, Draper (and most Rails + Decorator articles I could find) seem to be focused on view functionality...

Because decorators are designed to be consumed by the view, you should only be accessing them there. Manipulate your models to get things ready, then decorate at the last minute, right before you render the view. This avoids many of the common pitfalls that arise from attempting to modify decorators (in particular, collection decorators) after creating them.

Unlike view functionality, where you have a controller to ensure the models its passing to the view are decorated, my decorators are for model functionality. The decorator is mostly for code organization and ease of testing, just about everything should be using the decorated model.

What are the best practices for using decorators to add to model functionality? Always use the decorated models? Something more radical like moving subscription and unsubscription into another class?

Upvotes: 2

Views: 2666

Answers (1)

max
max

Reputation: 101811

I don't think this is a good fit for decorators. In rails decorators mainly wrap model objects with presentation logic used in the view. They work as an extension of a single object which let you separate the different tasks of an object logically.

For example:

class User
  def born_on
    Date.new(1989, 9, 10)
  end
end

class UserDecorator < SimpleDelegator
  def birth_year
    born_on.year
  end
end

Decorators are not a good fit when it comes to proceedure like operations where several objects interact.

Rather what you should be looking at is the service objects pattern, in which you create single purpose objects that perform a single task:

class SubscriptionService

  attr_accessor :user, :list

  def initialize(user, list)
    @user = user
    @list = list
  end

  def self.perform(user, list)
    self.new(user, list).perform
  end

  def perform
     @subscription = Subscription.new(user: @user, list: @list)
     log_subscription_attempted
     if @subscription.create
       send_welcome_email
       # ...
     else
       log_failure_reason
       # ...
     end

     @subscription
  end

  private

    def send_welcome_email
      # ...
    end

    def log_subscription_attempted
      # ...
    end

    def log_failure_reason
      # ...
    end
end

But you should also consider if you are composing your models correctly. In this example you would want to have three models interconnect as so:

class User
  has_many :subscriptions
  has_many :subscription_lists, through: :subscriptions
end

class Subscription
  belongs_to :user
  belongs_to :subscription_list
  validates_uniqueness_of :user_id, scope: :subscription_list_id
end

# or topic
class SubscriptionList 
  has_many :subscriptions
  has_many :users, through: :subscriptions
end

Each model should handle one separate entity/resource in the application. So the SubscriptionList model for example should not be directly involved in subscribing a single user. If your models are getting two fat it might be a sign that you are shoehorning too much into a too small set of business logic objects or that the database design is poorly set up.

Upvotes: 2

Related Questions