DEV Community

Vlad Hilko
Vlad Hilko

Posted on • Updated on

How to implement Policy Object pattern in Ruby on Rails?

In simple terms, Policy Object allows us to encapsulate complex business rules and is used to replace complex conditions.

Why do we need it and what problems can this pattern solve?

Sometimes we have complex business rules that are used directly in the business logic. For example, the following code can be used many times in different controllers and service objects:

def show
  user = User.first

  if user.status == 'active' && user.approved_at.present?
    render json: user
  else
    render status: :forbidden
  end
end
Enter fullscreen mode Exit fullscreen mode

Problems:

  • NOT possible to test separately from the controller.
  • NOT DRY
  • Violates single-responsibility principle
  • NOT clear, hard to read and understand what's going on.

So what can we do to fix it?

  • We can move this logic to the model level.
  • We can create a Policy Object class and move the logic there.

Let's take a look at these options one by one.


Model methods

Adding new model methods is the easiest option, we just need to extend our model in the following way:

# frozen_string_literal: true

class User < ApplicationRecord

  def active?
    status == 'active' && approved_at.present?
  end

  def cancelled?
    status == 'cancelled' && cancelled_at.present?
  end

end

user = User.last
user.active?
user.cancelled?
Enter fullscreen mode Exit fullscreen mode

Why do we need other options if the model methods solve our problems?

There're 2 problems:

  • The first one is that we violate the Single responsibility and the open-closed SOLID principles ('a class should basically serve one purpose' and 'a class/object should be open for extension, but closed for modification'). The model becomes too FAT and responsible for too many things. In real projects, we'll end up with a hundred different methods and lines of codes, because one such method can contain 10 private methods under the hood. You can probably imagine how difficult it would be to read, modify and maintain this class.
  • The second problem here is potential additional arguments. Right now we only use the user object, but in the future the business rules may require other objects as well, so it won't be easy to maintain such code.

That's why it would be nice to have the same interface, but move all the methods into a separate class.


How can we do that?


Meet Policy Object

We're going to implement Policy Object via plain Ruby object because it's the clearest and the most straightforward solution.

Let's take a look at the following example.

# app/units/policies/user.rb

# frozen_string_literal: true

module Policies
  class User

    def initialize(user)
      @user = user
    end

    def active?
      user.status == 'active' && user.approved_at.present?
    end

    def cancelled?
      user.status == 'cancelled' && user.cancelled_at.present?
    end

    private

    attr_reader :user

  end
end

user_policy = Policies::User.new(User.last)
user_policy.active?
user_policy.cancelled?
Enter fullscreen mode Exit fullscreen mode

We just encapsulate our rules inside a separated class and that's it. The following code stick to SOLID principle and can take as many arguments as we want. Pretty simple, isn't it? Let's take a look at other possible names that are commonly used inside policy objects to get a wider picture:

def apply?; end

def allowed?; end

def permitted?; end

def eligible?; end

def satisfy?; end

def excluded?; end

def locked?; end

def approved?; end

def not_approved?; end

def required?; end

# etc...
Enter fullscreen mode Exit fullscreen mode

Have you figured out what all these Policy Object methods have in common yet? Here they are:

  • Policy object methods always return boolean values (true or false)
  • Policy object methods always end with a ?
  • Policy objects must not have any side-effects

So the final solution may look as follows:

def show
  user = User.first

  if Policies::User.new(user).active?
    render json: user
  else
    render status: :forbidden
  end
end
Enter fullscreen mode Exit fullscreen mode

Conclusion

  • Thanks to Policy Object we can separate logic better and test our code more easily.
  • Policy Object is DRY and reusable
  • Policy Object allows us to avoid FAT model and stick to SOLID principles
  • Policy Object reduces "Cognitive Overhead", so the code is much easier to understand.

Latest comments (5)

Collapse
 
katafrakt profile image
Paweł Świątkowski

Policy objects are great. However, I don't 100% agree with examples in this post. Putting methods like active? in the model leads to fat models, but I'd argue it does not break the SRP (well... ActiveRecord itself breaks it, but it does not break it more than it's already broken ;) ). In your example, active? method relies solely on the state of the model instance, which IMO justifies putting it in the model.

In my mind policy objects usually coordinate the model with something else, often current_user. Having model know anything about current user is a huge smell, so policy object is much better at handling it. Something like this:

if Policies::Article.new(@article).can_update?(current_user)
  @article.save
end
Enter fullscreen mode Exit fullscreen mode

with

module Policies
  class Article
    def initialize(article)
      @article = article
    end

    def can_update?(user)
      user.role == :admin || @article.author_id == user.id
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

I think this might be more convincing for people who want to put everything in the model.

Collapse
 
vladhilko profile image
Vlad Hilko

yeah, you're right, I probably wouldn't have created a Policy object for such example, I just wanted to show the idea and keep the code as simple and straightforward as possible :)

Collapse
 
katafrakt profile image
Paweł Świątkowski

Sure thing.

By the way, which do you think is better? ;)

Policies::Article.new(@article).can_update?(current_user)

# or

Policies::Article.new(current_user).can_update?(@article)
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
vladhilko profile image
Vlad Hilko

I think if we call it Policies::Article then we should accept article in the initializer, so I would go with the following:

Policies::Article.new(@article).can_be_updated_by?(current_user)

# or 

Policies::User.new(current_user).can_update?(@article)
Enter fullscreen mode Exit fullscreen mode
Collapse
 
shayani profile image
Fernando Shayani

Nice approach for the fat models problem. Thanks!