Coach: An alternative to Rails controllers
Last editedJun 2024
Today we're open sourcing Coach, a library that removes the complexity from Rails controllers. Bundle your shared behaviour into highly robust, heavily tested middlewares and rely on Coach to join them together, providing static analysis over the entire chain. Coach ensures you only require a glance to see what's being run on each controller endpoint.
At GoCardless we've replaced all our controller code with Coach middlewares.
Why controller code is tricky
Controller code often suffers from hidden behaviour and tangled data dependencies, making it hard to read and difficult to test.
To keep your endpoints performant, you never want to be running a cacheable operation more than once. This leads to memoizing your database queries but the question is then where to store this data? If you want the rest of your controller code to be able to access it, then storing the data on the controller instance is the easiest way to make that happen.
In an attempt to reuse code, controller methods are then split out into controller concerns (mixins), which are included as needed. This leads to controllers that look skinny but have a large amount of included behaviour, all defined far from their call site.
Some of these implicitly defined methods are called in before_action
s, making it even
more unclear what code is being run when you hit your controllers. Inherited
before_action
s can lead to a controller that runs several methods before every
action without it being clear which these are.
One of the first things we do when a request hits GoCardless is parse authentication details and pull a reference to that access token out of the database. As the request progresses, we make use of that token to scope our database queries, tag our logs and verify permissions. This sharing and reuse of data is what makes writing controller code complex.
So how does Coach help?
Coach rethinks this approach by building your controller code around the data needed for
your request. All your controller code is built from Coach::Middleware
s, which take
a request context and decide on a response. Each middleware can opt to respond itself, or
call the next middleware in the chain.
Each middleware can specify the data it requires from those that have ran before it, and can declare what data it will pass to those that come after. This makes the flow of data explicit, and Coach will verify that the requirements have been met before you ever mount an endpoint.
Coach by example
The best way to see the benefits of Coach is with a demonstration...
Mounting an endpoint
class HelloWorld < Coach::Middleware
def call
# Middleware return a Rack response
[ 200, {}, ['hello world'] ]
end
end
So we've created ourselves a piece of middleware, HelloWorld
. As you'd expect,
HelloWorld
simply outputs the string 'hello world'
.
In an example Rails app, called Example
, we can mount this route like so...
Example::Application.routes.draw do
match "/hello_world",
to: Coach::Handler.new(HelloWorld),
via: :get
end
Once you've booted Rails locally, the following should return 'hello world'
:
$ curl -XGET http://localhost:3000/hello_world
Building chains
Suppose we didn't want just anybody to see our HelloWorld
endpoint. In fact, we'd like
to lock it down behind some authentication.
Our request will now have two stages, one where we check authentication details and another where we respond with our secret greeting to the world. Let's split into two pieces, one for each of the two subtasks, allowing us to reuse this authentication flow in other middlewares.
class Authentication < Coach::Middleware
def call
unless User.exists?(login: params[:login])
return [ 401, {}, ['Access denied'] ]
end
next_middleware.call
end
end
class HelloWorld < Coach::Middleware
uses Authentication
def call
[ 200, {}, ['hello world'] ]
end
end
Here we detach the authentication logic into its own middleware. HelloWorld
now uses
Authentication
, and will only run if it has been called via next_middleware.call
from
authentication.
Notice we also use params
just like you would in a normal Rails controller. Every
middleware class will have access to a request
object, which is an instance of
ActionDispatch::Request
.
Passing data through middleware
So far we've demonstrated how Coach can help you break your controller code into modular pieces. The big innovation with Coach, however, is the ability to explicitly pass your data through the middleware chain.
An example usage here is to create a HelloUser
endpoint. We want to protect the route by
authentication, as we did before, but this time greet the user that is logged in. Making
a small modification to the Authentication
middleware we showed above...
class Authentication < Coach::Middleware
provides :user # declare that Authentication provides :user
def call
return [ 401, {}, ['Access denied'] ] unless user.present?
provide(user: user)
next_middleware.call
end
def user
@user ||= User.find_by(login: params[:login])
end
end
class HelloUser < Coach::Middleware
uses Authentication
requires :user # state that HelloUser requires this data
def call
# Can now access `user`, as it's been provided by Authentication
[ 200, {}, [ "hello #{user.name}" ] ]
end
end
# Inside config/routes.rb
Example::Application.routes.draw do
match "/hello_user",
to: Coach::Handler.new(HelloUser),
via: :get
end
Coach analyses your middleware chains whenever a new Handler
is created. If any
middleware requires :x
when its chain does not provide :x
, we'll error out before the
app even starts.
Summary
Our problems with controller code were implicit behaviours, hidden data dependencies and as a consequence of both, difficult testing. Coach has tackled each of these, providing the framework to restructure own controllers into code that is easily understood, and easily maintained.
Coach also hooks into ActiveSupport::Notifications
, making monitoring performance of our
API really easy. We've written a little
adapter that sends detailed performance
metrics up to Skylight, where we can keep an eye out for sluggish endpoints.
Coach is on GitHub. As always, we love suggestions, feedback, and pull requests!