Rails components — neither engines nor gems

… and check why 5600+ Rails engineers read also this

Rails components — neither engines nor gems

There has been a very interesting discussion today on #ruby-rails-ddd community slack channel. The topic circulated around bounded contexts and introducing certain “component” solutions to separate them.

There are various approaches to achieve such separation — Rails Engines and CBRA were listed among them. It was, however, the mention of “unbuilt gems” that reminded me of something.

Gem as a code boundary

Back in the day, we had an approach in Arkency in which distinct code areas were extracted to gems. They had the typical gem structure with lib/ and spec/ and top-level gemspec file.

# top-level app directory

scanner/
├── lib
│   ├── scanner
│   │   ├── event.rb
│   │   ├── event_db.rb
│   │   ├── domain_events.rb
│   │   ├── scan_tickets_command.rb
│   │   ├── scanner_service.rb
│   │   ├── ticket.rb
│   │   ├── ticket_db.rb
│   │   └── version.rb
│   └── scanner.rb
├── scanner.gemspec
└── spec
    ├── scan_tickets_command_spec.rb
    ├── scan_tickets_flow_spec.rb
    └── spec_helper.rb
# scanner/scanner.gemspec

lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'scanner/version'

Gem::Specification.new do |spec|
  spec.name          = 'scanner'
  spec.version       = Scanner::VERSION

  # even more stuff here

  spec.add_dependency 'event_store'
end

Each gem had its own namespace, reflected in gem’s name. In example Scanner with scanner. It held code related to the scanning context, from the service level to the domain. You were able to run specs related to this particular area in separation. In fact, at that time we were just starting to use the term Bounded Context.

# scanner/lib/scanner.rb

module Scanner
end

require 'scanner/version'
require 'scanner/domain_events'
require 'scanner/scanner_service'
require 'scanner/scan_tickets_command'
require 'scanner/ticket'
require 'scanner/ticket_db'
require 'scanner/event'
require 'scanner/event_db'

Yet these gems were not like the others. We did not push them to RubyGems obviously. Neither did we store them on private gem server. They lived among Rails app, at the very top level in the code repository. They’re referenced in Gemfile using path:, like you’d do with vendored dependencies.

# Gemfile

gem 'scanner', path: 'scanner'

That way much of the versioning/pushing hassle was out of the radar. They could change simultaneously with the app that used them (starting in the controllers calling services from gems). Yet they organized cohesive concept in one place. Quite idyllic, isn’t it? Well, there was only one problem…

Rails autoloading and you

Code packaged as gem suffers from Rails code reload mechanism. While that rarely bothers you with the dependencies distributed from RubyGems that you’d never change locally, it is an issue for “unbuilt” gems.

Struggle with Rails autoload is real. If you keep losing battles with it — go read the guide thoroughly. That was also the reason we disregarded the gem approach.

Code component without gemspec

The solution we’re happy with now does not differ drastically from having vendored gems. There’s no gemspec but the namespace and directory structure from a gem stay. The gem entry in Gemfile is gone. Any runtime dependencies this gem had, go into Gemfile directly now.

What differs is that we no longer have require to load files. Instead, we use autoload-friendly require_dependency.

# scanner/lib/scanner.rb

module Scanner
end

require_dependency 'scanner/version'
require_dependency 'scanner/domain_events'
require_dependency 'scanner/scanner_service'
require_dependency 'scanner/scan_tickets_command'
require_dependency 'scanner/ticket'
require_dependency 'scanner/ticket_db'
require_dependency 'scanner/event'
require_dependency 'scanner/event_db'

With that approach, you also have to make sure that Rails is aware to autoload code from the path your Bounded Context lives in.

# config/application.rb

config.paths.add 'scanner/lib', eager_load: true

And that’s mostly it!

As an example you put Scanner::Ticket into scanner/lib/scanner/ticket.rb as:

# scanner/lib/scanner/ticket.rb

module Scaner
  class Ticket
  end
end

Dealing with test files

If you wish to painlessly run spec files in the isolated directory there are certain steps to take.

First, the spec helper should be responsible to correctly load the code.

# scanner/spec/spec_helper.rb

require_relative '../lib/scanner'

Then the test files should require it appropriately.

# scanner/spec/scan_tickets_command_spec.rb

require_relative 'spec_helper'

module Scanner
  RSpec.describe ScanTicketsCommand do
    # whatever it takes to gain confidence in code ;)
  end
end

Last but not least — it would be a pity to forget to run specs along the whole application test suite on CI. For this scenario we tend to put following code in app spec/ directory:

# spec/scanner_spec.rb

path = Rails.root.join('scanner/spec')
Dir.glob("#{path}/**/*_spec.rb") do |file|
  require file
end

Summary

The solution picture above is definitely not the only viable option. It has worked for me and my colleagues thus far. No matter which one you’re using — deliberate design with bounded contexts is a win.

You might also like