The Open-Closed Principle at Work within Devise

The SOLID principles of object-oriented programming define a set of guidelines that help produce well-written, maintainable software.

The “O” in SOLID stands for “Open for extension, closed for modification.” Though I’m well aware of this principle, I didn’t have a real-world example to hang my hat on, and emulate in my own code. Enter Devise, the widely-used authentication framework for Rails, where I recently found a perfect example of this principle.

Here is the situation - I need to add one or more roles to a new user upon registration. The role I add depends on some business logic. While Devise provides a registration controller, it doesn’t handle roles. I’ll need to use the Devise controller, but add my own logic for the role.

I have a few options:

  • Option 1: I could copy in the Devise code right from GitHub into my own controller, and add my own role logic directly to the method.

I’ve seen this tactic used in production code. Clearly, this is not ideal as the registration code is now in two (or more) locations. If the original Devise code changes in some way, the duplicated code would need changed as well. This is the path to bugs and headaches.

  • Option 2: I could make use of callbacks on the user model, adding the role at that point.

This option is better than option 1, but callbacks have their own issues. They tend to be a hidden side effect of an action, not always clear or obvious. Also, because different roles are added in different scenarios, all of that logic could end up in the callback. This still feels a little dirty, and not ideal.

  • Option 3: I could make use of the foresight of Devise’s authors, the Open-Closed Principle, and the flexibility of Ruby.

Let’s look at the actual Devise code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  # POST /resource
  def create
    build_resource(sign_up_params)

    resource.save
    yield resource if block_given?
    if resource.persisted?
      if resource.active_for_authentication?
        set_flash_message :notice, :signed_up if is_flashing_format?
        sign_up(resource_name, resource)
        respond_with resource, location: after_sign_up_path_for(resource)
      else
        set_flash_message :notice, :"signed_up_but_#{resource.inactive_message}" if is_flashing_format?
        expire_data_after_sign_in!
        respond_with resource, location: after_inactive_sign_up_path_for(resource)
      end
    else
      clean_up_passwords resource
      set_minimum_password_length
      respond_with resource
    end
  end

The ability to extend happens on line 6. Ruby’s yield indicates that the method allows for a block to be passed as part of the call, which is then executed as part of the method. Perfect, thank you Devise, thank you Ruby! Now I don’t need either of the less-desirable options.

My controller is as simple as:

1
2
3
4
5
6
7
8
  class RegistrationsController < Devise::RegistrationsController
    def create
      super do
        resource.add_role(:moderator)
        resource.save
      end
    end
  end

I need to register different types of users throughout the app, which is as easy as creating a separate controller for each scenario, encapsulating the different role logic in it’s own block, and passing that block to the Devise registration method. I’ve extended Devise’s registration method without modification, the Open-Closed Principle at work.