RailsDrivers
What are these "drivers"?
Each driver is like a mini Rails app that has full access to the main app. A driver has its own app
, config
, spec
, and db
folder.
Technically speaking, "driver" is just a fancy name for code that live in a different app folder. The advantage of doing this is that it provides clear-cut separation of concerns. If we follow a couple of simple rules, we can actually test that separation:
- Drivers should not touch other drivers
- The main app should not touch drivers directly
The "main app" refers to the files inside your <project root>/app
directory.
If your test suite is good enough (see Testing for coupling), you can test that these rules are adhered to by selectively adding and removing drivers before running your tests.
Aren't these just engines?
Very similar, yes. They use the same Rails facilities for adding new app
paths, etc.
But practically speaking, drivers come with less friction. They can be freely added and removed from your project without making changes to the main app. There's no need to mess around with gems, routes, or dummy apps.
Another difference is that drivers have a different dependency direction from engines. Engines are depended on by the Rails app:
depends on
(Rails App) --> (Engine)
A Rails app includes an engine as a plugin. The engine doesn't know how the Rails app works. For drivers, it's the other way around:
depends on
(Driver) --> (Rails App)
The Rails app doesn't know how its drivers work. It simply acts as a platform for all drivers to be built on. This makes drivers a great way to develop independent features that all rely on the same set of core functionality.
Usage
Every folder inside drivers
has its own app
, config
, db
, and spec
folders. They are effectively a part of the overall Rails app.
Creating a new driver
Run rails g driver my_new_driver_name
to get a scaffold driver.
Creating migrations for a driver
bundle exec driver my_driver_name generate migration blah etc_etc:string
The driver
utility technically works with other generators and rake tasks, but is only guaranteed to work with migrations.
The reason is that some generators have hard-coded path strings, rather than using the Rails path methods.
Creating a rake task in a driver
Every driver includes a lib/tasks
directory where you can define rake tasks. Rake tasks defined in drivers are automatically loaded and namespaced.
For example,
# drivers/my_driver/lib/tasks/my_namespace.rake
namespace :my_namespace do
task :task_name do
end
end
Can be executed using rake driver:my_driver:my_namespace:task_name
.
Extensions
Sometimes you want to add a method to a core class, but that method will only be used by one driver. This can be achieved by adding files to your driver's extensions
directory.
# app/models/product.rb
# (doesn't have to be a model - can be anything)
class Product < ApplicationRecord
# When you include this, every driver's product_extension.rb is loaded and
# included. Works correctly with autoloading during development.
include RailsDrivers::Extensions
end
# drivers/my_driver/extensions/product_extension.rb
module MyDriver
module ProductExtension
extend ActiveSupport::Concern
def new_method
'Please only call me from code inside my_driver'
end
end
end
# Anywhere in my_driver (or elsewhere, but that's bad style)
Product.new.new_method
For each Extension, the accompanying class simply includes
it, so any methods you define will be available throughout the whole app. To make sure your drivers don't change the core behavior of the app, see Testing for coupling.
Testing for coupling
Since drivers are merged into your main application just like engines, there's nothing stopping them from accessing other drivers, and there's nothing stopping your main application from accessing drivers. In order to ensure those things don't happen, we have a handful of rake tasks:
rake driver:isolate[<name of driver>] # leaves you with only one driver
rake driver:clear # removes all drivers
rake driver:restore # restores all drivers
Suppose you have a driver called store
and a driver called admin
. You don't want store
and admin
to talk to each other.
# Run specs with store driver only
rake driver:isolate[store]
rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb'
rake driver:restore
# Run specs with admin driver only
rake driver:isolate[admin]
rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb'
rake driver:restore
# Short-hand with 'driver' utility!
bundle exec driver admin do rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb'
# (can run with no drivers as well)
bundle exec nodriver do rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb'
# Or you can move the driver folders around manually
mv drivers/admin tmp/drivers/admin
bundle exec rspec --pattern '{spec,drivers/*/spec}/**{,/*/**}/*_spec.rb'
mv tmp/drivers/admin drivers/admin
This lets you to ensure that the store and admin function properly without each other. Note we're running all of the main app's specs twice. This is good because we also want to make sure the main app is not reaching into drivers.
Of course there's nothing stopping you from using if-statements to detect whether a driver is present. It's up to you to determine what's a "safe" level of crossover. Generally, if you find yourself using a lot of those if-statements, you should consider rethinking which functionality belongs in a driver and which functionality belongs in your main app. On the other hand, the if-statements provide clear feature boundaries and can function as feature flags. Turning off a feature is as simple as removing a folder from drivers
.
Installation
Add this line to your application's Gemfile:
Install the gem
gem 'rails_drivers'
And then execute:
$ bundle install
Update routes file
Add these lines to your routes.rb:
# config/routes.rb in your main Rails app
require 'rails_drivers/routes'
# This can go before or after your application's route definitions
RailsDrivers::Routes.load_driver_routes
This will tell your main Rails app to load the routes.rb
files generated in each of your drivers.
RSpec
If you use RSpec with FactoryBot, add these lines to your spec/rails_helper.rb
or spec/spec_helper.rb
:
Dir[Rails.root.join("drivers/*/spec/support/*.rb")].each { |f| require f }
RSpec.configure do |config|
FactoryBot.definition_file_paths += Dir['drivers/*/spec/factories']
FactoryBot.reload
Dir[Rails.root.join('drivers/*/spec')].each { |x| config.project_source_dirs << x }
Dir[Rails.root.join('drivers/*/lib')].each { |x| config.project_source_dirs << x }
Dir[Rails.root.join('drivers/*/app')].each { |x| config.project_source_dirs << x }
end
Webpacker
If you use Webpacker, take a look at this snippet. You'll want to add the code between the comments:
// config/webpack/environment.js
const { environment } = require('@rails/webpacker')
//// Begin driver code ////
const { config } = require('@rails/webpacker')
const { sync } = require('glob')
const { basename, dirname, join, relative, resolve } = require('path')
const extname = require('path-complete-extname')
const getExtensionsGlob = () => {
const { extensions } = config
return extensions.length === 1 ? `**/*${extensions[0]}` : `**/*{${extensions.join(',')}}`
}
const addToEntryObject = (sourcePath) => {
const glob = getExtensionsGlob()
const rootPath = join(sourcePath, config.source_entry_path)
const paths = sync(join(rootPath, glob))
paths.forEach((path) => {
const namespace = relative(join(rootPath), dirname(path))
const name = join(namespace, basename(path, extname(path)))
environment.entry.set(name, resolve(path))
})
}
sync('drivers/*').forEach((driverPath) => {
addToEntryObject(join(driverPath, config.source_path));
})
//// End driver code ////
module.exports = environment
License
The gem is available as open source under the terms of the MIT License.