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:
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.
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.
Let's look at the actual Devise code.
# 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:
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.
Written by Alex Brinkman who lives and works in Denver, but plays in the mountains.