rails architecture engines devise authentication rmm

I have been working extensively with rails engines and building modular monoliths the last few years. It started off experimental, triggered by a great blog post by Dan Manges. Over the years, my experiments have taken shape and I have learned a lot in the process.

We continue to make a lot of engines and improve our engine creation process. Some of our setup is opinionated but it should still serve as a good demonstration.

Creating the Engine

Source Code

We often use a user engine to handle our application’s authentication. It would contain our User’s, Account’s, Session’s, etc. I’m going to walk you through the creation of a user engine that we call ConnectBy.

Note: I used to use names like UserEngine and ReportEngine, however we have adopted a new convention shared at RailsConf 2020.

Create a host app

I’ll refer to our rails application as the host application. You can follow these steps with an existing rails application or with a new one.

rails new host_app --database postgresql

For this tutorial, we will setup Devise which is also an engine. Let’s get started.

Generate the plugin

I store my engines in /engines and gems in /gems. Run the rails plugin generator from the host’s root.

./host_app $ rails plugin new engines/connect_by --mountable --database postgresql --skip-git --skip-keeps --skip-action-text --skip-action-cable --skip-sprockets --skip-javascript --skip-turbolinks --skip-test --skip-system-test --skip-gemfile-entry --dummy_path=spec/dummy

Flags explained

We skip setting up test_unit and create a dummy app that we will later use with rspec.

--skip-test --skip-system-test --dummy_path=spec/dummy

Our host app will dynamically load all of our engines.

--skip-gemfile-entry

Our frontend won’t live in our engine.

--skip-action-cable --skip-sprockets --skip-javascript --skip-turbolinks

Version Management

Add a rails-version file to our host app so each engine can reference it.

./host_app $ echo "6.0.3.2" > .rails-version

Update the gemspec

# engines/connect_by/connect_by.gemspec

$:.push File.expand_path("lib", __dir__)

require "connect_by/version"

rails_version = File.read(File.join(__dir__, "../../.rails-version"))

Gem::Specification.new do |spec|
  spec.name        = "connect_by"
  spec.version     = ConnectBy::VERSION
  spec.authors     = ["David Amrani"]
  spec.summary     = "User Authentication"
  spec.description = "User Authentication"
  spec.license     = "MIT"

  spec.files = Dir["{app,config,db,lib}/**/*", "Rakefile"]

  spec.add_dependency "rails", rails_version
  spec.add_dependency "devise", "~> 4.7.1"
  spec.add_dependency "pg", "~> 1.2.3"
  spec.add_dependency "bcrypt", "3.1.13"

  spec.add_development_dependency "pg"
end

Load our engine in the host application’s gemfile.

# Gemfile

Dir.glob(File.expand_path("../engines/*", __FILE__)).each do |path|
  gem File.basename(path), path: path
end

Install Devise

Review devise’s latest getting start instructions and use their devise inside a mountable engine wiki as an additional resource.

Add devise to our engine’s gemfile.

# engines/connect_by/Gemfile
gem "devise", "~> 4.7.1"

Load devise.

# engines/connect_by/lib/connect_by.rb

require "connect_by/version"

module ConnectBy
end

require "devise"
require "connect_by/engine"

Bundle connect_by and our host.

./host_app $ bundle install
./host_app/engines/connect_by $ bundle install

Install devise and create our user model.

./host_app/engines/connect_by $ rails generate devise:install
./host_app/engines/connect_by $ rails generate devise user

Update our devise configuration.

# engines/connect_by/initializers/devise.rb
config.parent_controller = 'ConnectBy::ApplicationController'
config.router_name = :connect_by

Install our new migrations on the host application.

./host_app $ rails connect_by:install:migrations

Migrate your host app’s database.

./host_app $ rails db:create db:migrate

Mount our engine’s route in the host.

# config/routes.rb

Rails.application.routes.draw do
  mount ConnectBy::Engine, at: "/a"
end

We are creating an isolate namespaced engine so we need to tell devise that we are using their controllers in their module.

# engines/connect_by/config/routes.rb
devise_for :users,
  class_name: "ConnectBy::User",
  module: :devise

Frontend

I prefer to exclude the frontend from connect_by. As I already mentioned, this setup is opinionated. Isolated frontends with Webpacker 4 are troublesome (How to use webpacker from within engines?). However I suspect an official solution soon with Webpacker 5.

I’ll remove assets and view layouts from the engine.

./host_app/engines/connect_by $ rm -rf ./app/assets
./host_app/engines/connect_by $ rm -rf ./app/views/layouts

Start up the server and navigate to /a/users/sign_in to confirm it worked.

Takeway

We created a rails 6 engine to handle our app’s user authentication within our modular monolith architecture. We installed Devise and removed our frontend.

The source code for this part is on github.

Next Steps

Part 2 will setup testing.

Useful Resources

I have compiled a list of useful resources for rails engines and the modular monolith architecture.

Scale With Rails Engines

Need help scaling your rails application with a modular monolith? Talk to us