- Time: 20-30 min
- Level: Intermediate/Advanced
- Code: GitHub
- Reference: The Modular Monolith: Rails Architecture – DanManges
Referenced article is among the best I’ve read in the past year. While Idon’t agree with everything stated there, the ideas described aresuper awesome (this is when I found out there is a limit on a number ofclaps you can give in medium). I’ve tried to apply them in my pet projectthat is built with classic Rails structre - while extraction wasn’t easythe result was definitely worth it. Here I’ve extracted a gem and an enginefrom the project into a brand new Rails application and it was painless,moreover this extraction improved the design of the engine. I will coverimportant pieces of the setup below, but for TLDR people here is aGithub Repo, check the seed.rb
file for creadentials to use.
Gem Overview
The gem allowes you to fetch event data from a Google Calendar
separation of dependencies
Code in the local gem has it’s own dependencies but does not rely on a main app, these dependencies were moved from the parent app’s Gemfile into a gem’s *.gemspec
# gems/google_calendar/google_calendar.gemspec
32 spec.add_dependency 'activemodel'
33 spec.add_dependency 'google-api-client', '~> 0.11'
34 spec.add_dependency 'ice_cube'
35 # Development
36 spec.add_development_dependency 'pry-byebug'
37 spec.add_development_dependency 'simplecov'
38 spec.add_development_dependency 'rake'
39 spec.add_development_dependency 'rspec'
40 spec.add_development_dependency 'factory_bot'
and are loaded in initializer
# gems/google_calendar/lib/google_calendar.rb
1 require 'google/apis/calendar_v3'
2 require 'google/api_client/client_secrets'
3
4 require 'google_calendar/version'
5 require 'google_calendar/connection'
6 require 'google_calendar/event'
separation of tests
All Unit tests for the gem were moved to a gem’s folder, they can be executed independently and in isolation - navigate to a gems folder and run bundle exec rspec spec/
. In order to make tests run you will need to do a manual setup in helper file
# gems/google_calendar/spec/spec_helper.rb
1 require 'simplecov'
2 SimpleCov.start
3
4 require 'google_calendar'
5 require 'factory_bot'
6 require 'factories/event_factories'
Engine Overview
Engine provides authentication using authlogic gem
separation of dependencies
Same pattern as in gems, all dependencies live in engines *.gemspec anddo not pollute parent app’s Gemfile.
# domains/customers/customers.gemspec
19 s.add_dependency 'authlogic'
20 s.add_dependency 'best_in_place', '~> 3.0.1'
21 s.add_dependency 'draper'
22 s.add_dependency 'google_calendar'
23 s.add_dependency 'haml'
24 s.add_dependency 'rails'
25
26 s.add_development_dependency 'rspec-rails'
27 s.add_development_dependency 'factory_bot'
28 s.add_development_dependency 'shoulda-matchers'
29 s.add_development_dependency 'pry-byebug'
30 s.add_development_dependency 'sqlite3'
and are loaded in the initializer
# domains/customers/lib/customers.rb
1 require 'active_model/railtie'
2 require 'active_record/railtie'
3 require 'customers/engine'
4 require 'haml'
5 require 'best_in_place'
6 require 'authlogic'
engine also depends on a local google_calendar
gem, it is loaded directly in the Gemfile
# domains/customers/Gemfile
1 source 'https://rubygems.org'
2
3 gem 'google_calendar', path: '../../gems/google_calendar'
separation of tests
All Unit tests for the engine were moved to a engine’s folder, they can be executed independently and in isolation - navigate to a engine’s dummy application folder domains/customers/spec/dummy/
and run bundle exec rspec spec/
In order to make tests run you will need to do manual setup of the environment in the helper file
# domains/customers/spec/dummy/spec/rails_helper.rb
1 # Configure Rails Environment
2 ENV['RAILS_ENV'] = 'test'
3 require File.expand_path("../../config/environment.rb", __FILE__ )
4 # TOFIX ActiveRecord::Migrator.migrations_paths = [File.expand_path("../../test/dummy/db/migrate", __FILE__ )]
5 ActiveRecord::Migrator.migrations_paths << File.expand_path('../../db/migrate', __FILE__ )
6
7 require 'rspec/rails'
8 # Add additional requires below this line. Rails is not loaded until this point!
9 require 'spec_helper'
10 require 'authlogic'
11 require 'authlogic/test_case'
12 require 'factory_bot'
13 require 'shoulda-matchers'
14 require 'pry'
15
16 FactoryBot.factories.clear
17 FactoryBot.definition_file_paths = %W(spec/factories)
18 FactoryBot.reload
19 Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
20
21 RSpec.configure do |config|
22 config.include Authlogic::TestCase
23 config.include FactoryBot::Syntax::Methods
24 config.include Shoulda::Matchers::ActiveModel, type: :model
25 config.include Shoulda::Matchers::ActiveRecord, type: :model
26
27 config.filter_rails_from_backtrace!
28 end
separation of migrations
I believe migration files should not be copied to a parent application, required configuration is specified in engines initializer
# domains/customers/lib/customers/engine.rb
1 module Customers
2 class Engine < ::Rails::Engine
3 isolate_namespace Customers
4
5 initializer :append_migrations do |app|
6 # Migrations
7 config.paths['db/migrate'].expanded.each do |expanded_path|
8 app.config.paths['db/migrate'] << expanded_path
9 end
...
12 end
13 end
14 end
separation of translations
Translations for views from an engine also live in an engine, configuration is specified in engines initializer
1 module Customers
2 class Engine < ::Rails::Engine
3 isolate_namespace Customers
4
5 initializer :append_migrations do |app|
...
10 # Translations
11 config.i18n.load_path += Dir["#{config.root}/config/locales/**/*.yml"]
12 end
13 end
14 end
Enabling authentication in the main application
Authentication was extracted to be a controllers concern so you need to add this concern to a controller
# app/controllers/application_controller.rb
1 class ApplicationController < ActionController::Base
2 include Customers::Authorization
3 end
Testing Engine/Gem Integration into a main application
I believe that tests located in engines/gems should be unit tests - they should run fast and stub any external dependencies. It doesn’t make sense to me to test integration outside of the main applications - System Tests are great tool to do this job. A basic example
# test/system/login_test.rb
1 require 'application_system_test_case'
2 require File.join(Rails.root.to_s, 'test', 'support', 'pages', 'accounts', 'login')
3
4 class UsersTest < ApplicationSystemTestCase
5
6 def test_login_is_functional
7 load "#{Rails.root}/db/seeds.rb"
8 ::Pages::Accounts::Login.new(test: self, url: customers.login_url ).instance_eval do
9 visit
10 # Validate content
11 password_present?
12 login_present?
13 submit_present?
14 # Log in
15 login.set( Customers::Account.first.email )
16 password.set( 'Test1234' )
17 submit.click
18 assert_text( 'Accounts' )
19 end
20 ensure
21 Customers::Account.all.map(&:destroy!)
22 end
23 end
Summary
Extracting a gem was quite easy, extracting an engine was a bit of work. Advantages of the Modular Monolith over the classic app:
- Separation of code - dramatically improved application design
- Separation of dependencies - keeps main application cleaner
- Separation of tests - each unit has it’s own suite that is fast and can be run independently
Code:
Food for thought
- How to handle shared layouts?
- How to handle database tables shared between engines?
- Should Gemfile.lock from engines/gems exist in the Git repository?
Top comments (0)