Clear up your Rails controllers with before_action wrappers
What does clear code mean to you? To me, it's all about communication.
What does this say and how?
— and how is the system communicated in our specific case?
In our case, let's look at our Rails controllers.
Because there's a pattern I keep coming back to, but not many people know about it.
Today, let's learn how to define our own class method macros that wrap before_action
.
Our case: a feature flag based Rails controller
Here's a feature flag Rails controller backed by Flipper:
class User::LikesController < ApplicationController
def create
return unless Flipper.enabled?(:like, Current.user)
@like = Current.user.likes.create(like_params)
redirect_to @like.article
end
def destroy
return unless Flipper.enabled?(:like, Current.user)
@like = Current.user.likes.find(params[:id]).tap(&:destroy)
redirect_to @like.article
end
end
Note: it's borrowed and lightly adapted from the excellent How to add feature flags to your Ruby on Rails applications.
Our controller here either creates or destroys a Like
, but before that we require a feature flag with Flipper.enabled?
in both actions.
This code is perfectly fine, it doesn't need to be more DRY.
However, if we change our perspective we can help establish a wider approach that's more oriented towards our system.
In other words, there is a potential pattern here that we could help establish for coworkers.
In codebases it can be tough to know if what you're doing is a good approach, but having a wrapping abstraction helps enable us to we're doing ok.
A holistic approach helps codify our institutional response to a problem.
Trialing out our abstraction
Let's explore our abstraction by extracting our feature flag check into a common before_action
:
class User::LikesController < ApplicationController
before_action { throw :abort unless Flipper.enabled?(:like, Current.user) }
end
We use
throw :abort
to halt the callback chain, areturn
isn't enough.
Now that we've got a central location let's trial out some potential APIs to see if it's worth going further.
If we can't get a clearer name that communicates more than the underlying code, then I'd stop here.
class User::LikesController < ApplicationController
abort_without_feature :like
must_have_feature :like
requires_feature :like
end
This is just me writing different versions out and comparing the different naming and seeing which I like better. It's not a science.
Typically, for these
before_action
wrappers I tend to userequires_
,require_
,ensures_
orensure_
as the prefix, but it's good to try alternatives.
Let's go with requires_feature
and extract a class macro inline:
class User::LikesController < ApplicationController
def self.requires_feature(name)
before_action -> { throw :abort unless Flipper.enabled?(name, Current.user) }
end
requires_feature :like
end
Since the lambda here isn't a scope gate we can forward name
directly.
Upgrading our default response
I'd like something else than throw :abort
to be our response. In case a user accesses a feature they shouldn't I'd lean more towards head :bad_request
.
There's more nuance here, but this'll have to do for this post.
def self.requires_feature(name)
before_action { head :bad_request unless Flipper.enabled?(name, Current.user) }
end
Rounding out requires_feature
Whenver I do these before_action
wrappers, I tend to also:
- Forward keyword arguments
- Provide defaults but allow overrides
How to forward keyword arguments
def self.requires_feature(name, **)
before_action(-> { head :bad_request unless Flipper.enabled?(name, Current.user) }, **)
end
That's it. And now we can reuse before_action
's options like only:
and except:
, like this:
requires_feature :like, only: :new
requires_feature :like, except: [:destroy, :update]
How to provide a default, but allow overrides
I'm not the most well-versed in Flipper, but I think a Current.account
might be useful too, so we can do:
def self.requires_feature(name, from: :user, **)
before_action(-> { head :bad_request unless Flipper.enabled?(name, Current.public_send(from)) }, **)
end
And now in case we have a feature that's validated from the current account
, we can pass that:
requires_feature :like, from: :account
Where to put requires_feature
Now that we've verified our approach in one location, let's move it out.
# app/controllers/concerns/requires_feature.rb
module RequiresFeature
def requires_feature(name, from: :user, **)
before_action(-> { head :bad_request unless Flipper.enabled?(name, Current.public_send(from)) }, **)
end
end
Pro-tip: since this only adds a class method, I prefer to just use
extend
and omit theself.
. It also means we don't have toextend ActiveSupport::Concern
.
And now we can extend our ApplicationController
:
class ApplicationController < ActionController::Base
extend RequiresFeature
end
Every controller can now call requires_feature
. Congrats!
Resources
You can see another example of this pattern in my ActionController::StashedRedirects gem here.
There's also a Sudo
authentication example in my gem above that uses this pattern.
Rails also wraps before_action
's in more clarifying methods, here's two:
What's next?
Hopefully this pattern is as useful to you as it's been to me.
Are there places you're now thinking of applying it? I'd be curious to hear about it.