Engine-ering Rails apps

A presentation at Saint P Ruby Meetup in August 2019 in St Petersburg, Russia by Vladimir Dementyev

Slide 1

Slide 1

Component B Component A Component C Engine-ering Rails Applications Vladimir Dementyev, Saint-P Ruby 2019

Slide 2

Slide 2

Slide 3

Slide 3

We* are Doomed * Rails developers

Slide 4

Slide 4

Can you escape your destiny? V.Yurlov, for “The war of the worlds” by H.G.Wells

Slide 5

Slide 5

Slide 6

Slide 6

Components 👍

Slide 7

Slide 7

Monolith 😞

Slide 8

Slide 8

Hanami

Slide 9

Slide 9

Elixir

Slide 10

Slide 10

Shopify: Modular Monolith

Slide 11

Slide 11

SHOPIFY all others

Slide 12

Slide 12

Rails

Slide 13

Slide 13

The Book?

Slide 14

Slide 14

What we’ve done Or how we decided to go with engines

Slide 15

Slide 15

Co-living rentals service

Slide 16

Slide 16

Our mission • They had a property and lease managements system (admin panel) • They needed a community app for users • They wanted to keep everything in the same Rails app

Slide 17

Slide 17

Community • • • • Events Perks Chat Billing

Slide 18

Slide 18

Phase #1: Namespaces app/ controllers/ chat/… models/ chat/… jobs/ chat/…

Slide 19

Slide 19

Namespaces • Quick start • Fake isolation

Slide 20

Slide 20

Our mission evolves • There is a property and lease management application (admin panel) • We need a community app for users • We (they) want to keep everything in the same Rails app • And we (again, they) want to re-use the app’s code in the future for a side-project

Slide 21

Slide 21

Phase #2: Engines & Gems app/… engines/ chat/ app/ controllers/… … lib/… gems/…

Slide 22

Slide 22

The Modular Monolith: Rails Architecture

Slide 23

Slide 23

rails plugin new \ my_engine !—mountable Engems Building Rails apps from engines and gems

Slide 24

Slide 24

Crash Course in Engines $ rails plugin new my_engine \ !—mountable # or —full create create create create … create README.md Rakefile Gemfile my_engine.gemspec Engine is a gem

Slide 25

Slide 25

Crash Course in Engines my_engine/ app/ controllers/… … config/routes.rb lib/ my_engine/engine.rb my_engine.rb Added to paths

Slide 26

Slide 26

Crash Course in Engines # my_engine/lib/my_engine/engine.rb module MyEngine class Engine < !::Rails!::Engine # only in mountable engines isolate_namespace MyEngine end end

Slide 27

Slide 27

Crash Course in Engines # my_engine/config/routes.rb MyEngine!::Engine.routes.draw do get “/best_ruby_conference”, to: redirect(“https:!//spbrubyconf.ru”) end

Slide 28

Slide 28

Crash Course in Engines # <root>/Gemfile gem “my_engine”, path: “my_engine”

Slide 29

Slide 29

Crash Course in Engines # <root>/config/routes.rb Rails.application.routes.draw do mount MyEngine!::Engine !=> “/my_engine” end

Slide 30

Slide 30

Crash Course in Engines $ rake routes Prefix Verb … my_engine URI Pattern Controller#Action /my_engine MyEngine!::Engine Routes for MyEngine!::Engine: best_ruby_conference GET /best_ruby_conference(:format)

Slide 31

Slide 31

The end

Slide 32

Slide 32

Slide 33

Slide 33

Slide 34

Slide 34

Common Engines common-events perks_by connect_by active-storage-proxy chat_by meet_by manage_by main app

Slide 35

Slide 35

Dependencies • How to use non-Rubygems deps? • How to share common deps? • How to sync versions?

Slide 36

Slide 36

Path/Git problem # engines/my_engine/Gemfile gem “local-lib”, path: “!../local-lib” gem “git-lib”, github: “palkan/git-lib” # <root>/Gemfile gem “my_engine”, path: “engines/my_engine” gem “local-lib”, path: “!../local-lib” gem “git-lib”, github: “palkan/git-lib”

Slide 37

Slide 37

eval_gemfile # engines/my_engine/Gemfile eval_gemfile “./Gemfile.runtime” # engines/my_engine/Gemfile.runtime gem “local-lib”, path: “!../local-lib” gem “git-lib”, github: “palkan/git-lib” # <root>/Gemfile gem “my_engine”, path: “engines/my_engine” eval_gemfile “engines/my_engine/Gemfile.runtime”

Slide 38

Slide 38

eval_gemfile

Slide 39

Slide 39

Shared Gemfiles gemfiles/ profilers.gemfile rails.gemfile gem “stackprof”, “0.2.12” gem “ruby-prof”, “0.17.0” gem “memory_profiler”, “0.9.12” gem “rails”, “6.0.0.rc1”

Slide 40

Slide 40

Shared Gemfiles # engines/my_engine/Gemfile eval_gemfile “!../gemfiles/rails.runtime” eval_gemfile “!../gemfiles/profilers.runtime” # <root>/Gemfile eval_gemfile “gemfiles/rails.runtime” eval_gemfile “gemfiles/profilers.runtime”

Slide 41

Slide 41

Keep Versions N’Sync gem ‘transdeps’ A gem to find inconsistent dependency versions in component-based Ruby apps. NOT VER IFIED

Slide 42

Slide 42

DB vs. Engines • How to manage migrations? • How to write seeds? • How to namespace tables?

Slide 43

Slide 43

Migrations. Option #1 Install migrations: rails my_engine:install:migrations 🤔

Slide 44

Slide 44

Migrations. Option #2 “Mount“ migrations: # engines/my_engine/lib/my_engine/engine.rb initializer “my_engine.migrations” do |app| app.config.paths[“db/migrate”].concat( config.paths[“db/migrate”].expanded ) # For checking pending migrations ActiveRecord!::Migrator.migrations_paths += config.paths[“db/migrate”].expanded.flatten end 😺

Slide 45

Slide 45

Seeds # <root>/db/seed.rb ActiveRecord!::Base.transaction do ConnectBy!::Engine.load_seed PerksBy!::Engine.load_seed ChatBy!::Engine.load_seed MeetBy!::Engine.load_seed end

Slide 46

Slide 46

table_name_prefix class CreateConnectByInterestTags < ActiveRecord!::Migration include ConnectBy!::MigrationTablePrefix def change create_table :interest_tags do |t| t.string :name, null: false end end end

Slide 47

Slide 47

table_name_prefix module ConnectBy module MigrationTablePrefix def table_prefix ConnectBy.table_name_prefix end def table_name_options(config = ActiveRecord!::Base) { table_name_prefix: “!#{table_prefix}!#{config.table_name_prefix}”, table_name_suffix: config.table_name_suffix } end end end

Slide 48

Slide 48

table_name_prefix

config/application.rb config.connect_by.table_name_prefix = “connect_”

Slide 49

Slide 49

Factories # engines/my_engine/lib/my_engine/engine.rb initializer “my_engine.factories” do |app| factories_path = root.join(“spec”, “factories”) Custom load hook ActiveSupport.on_load(:factory_bot) do require “connect_by/ext/factory_bot_dsl” FactoryBot.definition_file_paths.unshift factories_path end end

Slide 50

Slide 50

Factories: Aliasing using ConnectBy!::FactoryBotDSL Takes engine namespace into account FactoryBot.define do # Uses ConnectBy.factory_name_prefix + “city” as the name factory :city do sequence(:name) { |n| Faker!::Address.city + ” (!#{n})”} trait :private do visibility { :privately_visible } end end end

Slide 51

Slide 51

Factories: Aliasing # spec/support/factory_aliases.rb unless ConnectBy.factory_name_prefix.empty? RSpec.configure do |config| config.before(:suite) do FactoryBot.factories.map do |factory| next unless factory.name.to_s.starts_with?(ConnectBy.factory_name_prefix) FactoryBot.factories.register( factory.name.to_s.sub(/^!#{ConnectBy.factory_name_prefix}/, “”).to_sym, factory ) end end end end

Slide 52

Slide 52

Factories: Aliasing • Use short (local) name when testing the engine • Use long (namespaced) when using in other engines

Slide 53

Slide 53

Shared Contexts my_engine/ lib/ my_engine/ testing/ shared_contexts/… shared_examples/… testing.rb

Slide 54

Slide 54

Shared Contexts # other_engine/spec/rails_helper/rb require “connect_by/testing/shared_contexts” require “connect_by/testing/shared_examples”

Slide 55

Slide 55

How to test engines?

Slide 56

Slide 56

Testing. Option #1 Using a full-featured Dummy app: spec/ dummy/ app/ controllers/ … config/ db/ test/ … 😕

Slide 57

Slide 57

Testing. Option #2 gem ‘combustion’ A library to help you test your Rails Engines in a simple and effective manner, instead of creating a full Rails application in your spec or test folder. 🙃

Slide 58

Slide 58

Combustion begin Combustion.initialize! :active_record, :active_job do config.logger = Logger.new(nil) config.log_level = :fatal config.autoloader = :zeitwerk config.active_storage.service = :test config.active_job.queue_adapter = :test end rescue !=> e # Fail fast if application couldn’t be loaded $stdout.puts “Failed to load the app: !#{e.message}\n” \ “!#{e.backtrace.take(5).join(“\n”)}” exit(1) end

Slide 59

Slide 59

Combustion spec/ internal/ config/ storage.yml db/ schema.rb

Slide 60

Slide 60

Combustion • Load only required Rails frameworks • No boilerplate, only the files you need • Automatically re-runs migrations for every test run

Slide 61

Slide 61

CI commands: engem: description: Run engine/gem build parameters: target: type: string steps: - run: Skip if no relevant changes name: “[!<< parameters.target !>>] bundle install” command: | .circleci/is-dirty !<< parameters.target !>> !|| \ bundle exec bin/engem !<< parameters.target !>> build

Slide 62

Slide 62

.circleci/is-dirty pattern = File.join(!dir!, “!../{engines,gems}//.gemspec”) gemspecs = Dir.glob(pattern).map do |gemspec_file| Gem!::Specification.load(gemspec_file) end names = gemspecs.each_with_object({}) do |gemspec, hash| hash[gemspec.name] = [] end # see next slide

Slide 63

Slide 63

.circleci/is-dirty tree = gemspecs.each_with_object(names) do |gemspec, hash| deps = Set.new(gemspec.dependencies.map(&:name)) + Set.new(gemspec.development_dependencies.map(&:name)) local_deps = deps & Set.new(names.keys) local_deps.each do |local_dep| hash[local_dep] !<< gemspec.name end end # invert tree to show which gem depends on what tree.each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |(name, deps), index| deps.each { |dep| index[dep] !<< name } index end

Slide 64

Slide 64

.circleci/is-dirty def dirty_libraries changed_files = git diff $(git merge-base origin/master HEAD) !--name-only .split(“\n”) raise “failed to get changed files” unless $!?.success? changed_files.each_with_object(Set.new) do |file, changeset| if file =~ %r{^(engines|gems)/([^/]+)} changeset !<< Regexp.last_match[2] end changeset end end

Slide 65

Slide 65

engines: executor: rails steps: - attach_workspace: at: . - engem: target: connect_by - engem: target: perks_by - engem: target: chat_by - engem: target: manage_by - engem: target: meet_by CI

Slide 66

Slide 66

Dev Tools • rails plugins new is too simple • Use generators: rails g engine

Slide 67

Slide 67

Generators # lib/generators/engine/engine_generator.rb class EngineGenerator < Rails!::Generators!::NamedBase source_root File.expand_path(“templates”, !dir!) def create_engine directory(“.”, “engines/!#{name}”) end end

Slide 68

Slide 68

Dev Tools • rails plugins new is too simple • Use generators: • Manage engines: rails g engine bin/engem

Slide 69

Slide 69

bin/engem # run a specific test $ ./bin/engem connect_by rspec spec/models/connect_by/city.rb:5

runs bundle install, rubocop and rspec by default $ ./bin/engem connect_by build

generate a migration $ ./bin/engem connect_by rails g migration <name>

you can run command for all engines/gems at once by using “all” name $ ./bin/engem all build

Slide 70

Slide 70

Engine Engine Main app

Slide 71

Slide 71

How to modify other engine’s entities?

Slide 72

Slide 72

Base & Behaviour • Base Rails classes within an engine MUST be configurable • They also MAY require to have a certain “interface”

Slide 73

Slide 73

Base & Behaviour module ConnectBy class ApplicationController < Engine.config.application_controller.constantize raise “Must include ConnectBy!::ControllerBehaviour” unless self < ConnectBy!::ControllerBehaviour raise “Must implement #current_user method” unless instance_methods.include?(:current_user) end end

Slide 74

Slide 74

Base & Behaviour module ConnectBy class ApplicationController < Engine.config.application_controller.constantize raise “Must include ConnectBy!::ControllerBehaviour” unless self < ConnectBy!::ControllerBehaviour raise “Must implement #current_user method” unless instance_methods.include?(:current_user) end end Configurable

Slide 75

Slide 75

Base & Behaviour # config/application.rb config.connect_by.application_controller = “MyApplicationController”

Slide 76

Slide 76

Base & Behaviour module ConnectBy class ApplicationController < Engine.config.application_controller.constantize raise “Must include ConnectBy!::ControllerBehaviour” unless self < ConnectBy!::ControllerBehaviour raise “Must implement #current_user method” unless instance_methods.include?(:current_user) end end “Interface”

Slide 77

Slide 77

Base & Behaviour class ApplicationController < ActionController!::API include ConnectBy!::ControllerBehaviour include JWTAuth include RavenContext if defined?(Raven) end

Slide 78

Slide 78

Modify Models • Prefer extensions over patches • Use load hooks (to support autoload/ reload)

Slide 79

Slide 79

Load Hooks # engines/connect_by/app/models/connect_by/city.rb module ConnectBy class City < ActiveRecord!::Base # !!… ActiveSupport.run_load_hooks( “connect_by/city”, self ) end end

Slide 80

Slide 80

Load Hooks # engines/meet_by/lib/meet_by/engine.rb initializer “meet_by.extensions” do ActiveSupport.on_load(“connect_by/city”) do include !::MeetBy!::Ext!::ConnectBy!::City end end

Slide 81

Slide 81

Load Hooks # engines/meet_by/app/models/ext/connect_by/city.rb module MeetBy module Ext module ConnectBy module City extend ActiveSupport!::Concern included do has_many :events, class_name: “MeetBy!::Events!::Member”, inverse_of: :user, foreign_key: :user_id, dependent: :destroy end end end end end

Slide 82

Slide 82

How to communicate between engines?

Slide 83

Slide 83

Problem • Some “events” trigger actions in multiple engines • E.g. , user registration triggers chat membership initialization, manager notifications, etc. • But user registration “lives” in connect_by and have no idea about chats, managers, whatever 🤔

Slide 84

Slide 84

Solution • Events • Events • Events

Slide 85

Slide 85

Tools • Hanami Events • Rails Events Store • Wisper (?) • dry-events (?)

Slide 86

Slide 86

Slide 87

Slide 87

Railsy RES

Slide 88

Slide 88

Railsy RES vs RES • Class-independent event types • Uses RES as interchangeable adapter • Better testing tools • Less verbose API and convention over configuration

Slide 89

Slide 89

Railsy RES class ProfileCompleted < Railsy!::Events!::Event # (optional) event identifier is used for transmitting events # to subscribers. # # By default, identifier is equal to name.underscore.gsub('/', '.'). self.identifier = “profile_completed” # Add attributes accessors attributes :user_id # Sync attributes only available for sync subscribers sync_attributes :user end

Slide 90

Slide 90

Railsy RES event = ProfileCompleted.new(user_id: user.id) # or with metadata event = ProfileCompleted.new( user_id: user.id, metadata: { ip: request.remote_ip } ) # then publish the event Railsy!::Events.publish(event)

Slide 91

Slide 91

Railsy RES initializer “my_engine.subscribe_to_events” do ActiveSupport.on_load “railsy-events” do |store| # async subscriber is invoked from background job, # enqueued after the current transaction commits store.subscribe MyEventHandler, to: ProfileCreated # anonymous handler (could only be synchronous) store.subscribe(to: ProfileCreated, sync: true) do |event| # do something end # subscribes to ProfileCreated automatically store.subscribe OnProfileCreated!::DoThat end end Convention over configuration

Slide 92

Slide 92

Railsy RES engines/ connect_by/ events/ connect_by/ users/ registered.rb chat_by/ subscribers/ connect_by/ users/ on_registered/ create_chat_account.rb

Slide 93

Slide 93

What we implement in the main app?

Slide 94

Slide 94

Main App • Authentication

Slide 95

Slide 95

Authentication module ConnectBy class ApplicationController < Engine.config.application_controller.constantize raise “Must include ConnectBy!::ControllerBehaviour” unless self < ConnectBy!::ControllerBehaviour raise “Must implement #current_user method” unless instance_methods.include?(:current_user) end end Engines don’t care about how do you obtain the current user

Slide 96

Slide 96

Main App • Authentication • Feature/system tests • Locales and mailers templates • Instrumentation and exception handling • Configuration

Slide 97

Slide 97

Gems Or stop putting everything into lib/ folder

Slide 98

Slide 98

Gems • Shared non-application specific code between engines • Isolated tests • Ability to share between applications in the future (e.g. , GitHub Package Registry)

Slide 99

Slide 99

Gems gems/ common-rubocop/ common-testing/ common-graphql/ …

Slide 100

Slide 100

common-rubocop • Standard RuboCop configuration (based on standard gem) • RuboCop plugins (e.g. , rubocoprspec) • Custom cops

Slide 101

Slide 101

common-rubocop # .rubocop.yml inherit_gem: common-rubocop: config/base.yml

Slide 102

Slide 102

common-testing • RSpec tools bundle (factory_bot, faker, test-prof, formatters) • Common spec_helper.rb and rails_helper.rb

Slide 103

Slide 103

common-testing # rails_helper.rb require “common/testing/rails_configuration”

Slide 104

Slide 104

common-graphql • Base classes (object, mutation) • Additional scalar types • Batch loaders • Testing helpers

Slide 105

Slide 105

Why engines? And why not

Slide 106

Slide 106

Why engines? • Modular monolith instead of monolith monsters or micro-services hell • Code (and tests) isolation • Loose coupling (no more spaghetti logic)

Slide 107

Slide 107

Why engines? • Easy to upgrade frameworks (e.g. , Rails) component by component • Easy to re-use logic in another app • …and we can migrate to micro-services in the future (if we’re brave enough)

Slide 108

Slide 108

Why not engines? • Not so good third-party gems support • Not so good support within Rails itself

Slide 109

Slide 109

Thanks! Спасибо!