Rails with Mingines (Minimized Engines)

Rails with minimized engines

Intro

This article was inspired by [Julián Pinzón] and his talk at Ruby Australia 2023 All you need is Rails (Engines): Compartmentalising your Monolith.

Its widely known that Engines are a power way to create a full ‘sub-rails application’. However, I’ve struggled to enjoy engine usage, in particular with the front-end aspects. Thus, I liked the idea of low overhead modularization - just using modules and an updated rails config Rails with Protected Modules. However, if you want to have the ability to make a fully independent Engine that can be distributed eventually as a gem - but for mow avoiding a full engine, this article describes doing this.

I like calling these mingines - minimized engines.

The code in this article available on rails_mingine.

create a base rails project

note: I like rspec so - I’ll skip the tests

rails new rails_mingines --javascript=esbuild --css=tailwind --skip-test
cd classrooms
bin/rails db:create

# lets make a place for our mingines - minimized-engines
mkdir mingines

lets create a rails core engine

# we will make a landing folder:
mkdir -p mingines/core

# make necessary 'mingine' folders
mkdir -p mingines/core/app
mkdir -p mingines/core/config
mkdir -p mingines/core/lib

# lets make the engine file - (it's a rails loading-helper)
mkdir -p mingines/core/lib/core
touch mingines/core/lib/core/engine.rb

cat <<EOF > mingines/core/lib/core/engine.rb
module Core
  class Engine < Rails::Engine
    # this engine should not use isolated namespace
    # isolate_namespace Core
  end
end
EOF

# routes
mkdir -p mingines/core/config
touch mingines/core/config/routes.rb
cat <<EOF > mingines/core/config/routes.rb
Core::Engine.routes.draw do
end
EOF

now lets move our rails files into this engine

mv app/* mingines/core/app/.

# however assets and js are easiest to leave in place
mv app/controllers/assets app/.
mv app/controllers/javascript app/.

now we need to update confg/application.rb with:

  • require_relative '../mingines/core/lib/core/engine.rb' to load our engine
  • config.paths['db/migrate'] << 'mingines/*/db/migrate' to find future migrations

Now it should look like:

require_relative "boot"

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

# we need to tell rails where to find and load our engines
# if this is confusing you can add each one individually with:
# require_relative '../mingines/core/lib/core/engine.rb'
# to automate this we can do the following: https://stackoverflow.com/questions/1899072/getting-a-list-of-folders-in-a-directory
Dir.chdir('mingines') do
  Dir.glob('*').select { |f| File.directory? f }.each do |name|
    require_relative "../mingines/#{name}/lib/#{name}/engine.rb"
  end
end

module RailsMingines
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 7.1

    # we need to tell rails where to find the engine migrations
    config.paths['db/migrate'] << 'mingines/*/db/migrate'

    # Don't generate system test files.
    config.generators.system_tests = nil
  end
end

lets start rails

bin.rails server

and be sure it all still works

cool let’s commit:

git add .
git commit -m 'added a rails - core engine'

Landing-page engine

This is of course overdone for a single page, but for demo purposes we will pretend we have large complex landing-page that needs its own namespace

Instead instead of running: bin/rails plugin new landing --mountable --skip-git

we will instead build the engines ourselves (without the gemspecs and other extras to make our engine portable)

# we will make a landing folder:
mkdir -p mingines/landing

# make necessary 'mingine' folders
mkdir -p mingines/landing/app
mkdir -p mingines/landing/config
mkdir -p mingines/landing/db/migrate
mkdir -p mingines/landing/lib

# lets make the engine file - (it's a rails loading-helper)
mkdir -p mingines/landing/lib/landing
touch mingines/landing/lib/landing/engine.rb

cat <<EOF > mingines/landing/lib/landing/engine.rb
module Landing
  class Engine < Rails::Engine
    # ensures we have our own namespace within our engine
    isolate_namespace Landing
  end
end
EOF

# routes
mkdir -p mingines/landing/config
touch mingines/landing/config/routes.rb
cat <<EOF > mingines/landing/config/routes.rb
Landing::Engine.routes.draw do
end
EOF

So we can build our home page controller:

# we can build it with the normal rails generator
bin/rails g controller landing/home index --no-helper

# now we see that we have created: controllers, views and routes
mkdir -p mingines/landing/app/controllers
mkdir -p mingines/landing/app/views

# copy our new files into our 'mingine'
mv app/controllers/landing mingines/landing/app/controllers/.
mv app/views/landing mingines/landing/app/views/.

Now we will need to update the ‘mingine’ route

mkdir -p mingines/landing/config
touch mingines/landing/config/routes.rb

cat <<EOF > mingines/landing/config/routes.rb
Rails.application.routes.draw do
  get 'home/index'

  root "home#index"
end
EOF

NOW we need to update config/routes.rb so it looks like:

Rails.application.routes.draw do
  mount Landing::Engine => '/'
  # ...
end

Now we should have our landing page when we go to: localhost:3000

assuming it works we can add a commit:

git add .
git commit -m 'added landing page minigine'

Let’s try an Engine with Data Models

we will build the engines with just what we need

# we will make a landing folder:
mkdir -p mingines/blogs

# make necessary 'mingine' folders
mkdir -p mingines/blogs/app
mkdir -p mingines/blogs/config
mkdir -p mingines/blogs/db/migrate
mkdir -p mingines/blogs/lib

# lets make the engine (loading helper)
mkdir -p mingines/blogs/lib/blogs
touch mingines/blogs/lib/blogs/engine.rb

# create a loading engine that keeps 'landing' as an isolated namespace
cat <<EOF > mingines/blogs/lib/blogs/engine.rb
module Blogs
  class Engine < Rails::Engine
    isolate_namespace Blogs
  end
end
EOF

# routes
mkdir -p mingines/blogs/config
touch mingines/blogs/config/routes.rb
cat <<EOF > mingines/blogs/config/routes.rb
Blogs::Engine.routes.draw do
end
EOF

Let’s create our models (etc) - again using standard generators

bin/rails g scaffold blogs/user full_name email --no-helper
bin/rails g scaffold blogs/article title body:text blogs_user:references --no-helper

# create missing folders
mkdir mingines/blogs/app/controllers
mkdir mingines/blogs/app/models
mkdir mingines/blogs/app/views
mkdir mingines/blogs/db
mkdir mingines/blogs/db/migrate

# copy our new code
mv app/controllers/blogs mingines/blogs/app/controllers/.
mv app/models/blogs mingines/blogs/app/models/.
mv app/models/blogs.rb mingines/blogs/app/models/.
mv app/views/blogs mingines/blogs/app/views/.

mv db/migrate/* mingines/blogs/db/migrate/.

now we need to update our mingine routes with:

Blogs::Engine.routes.draw do
  resources :articles
  resources :users
end

now lets update the core routes:

Rails.application.routes.draw do
  mount Blogs::Engine, at: 'blogs'
  # ...
end

Now we need to make 3 adjustments for the generators (of course if you are experienced you can avoid this and just create the necessary files yourself):

First - the models classnames

# mingines/blogs/app/models/blogs/articles.rb
class Blogs::Article < ApplicationRecord
  belongs_to :blogs_user, class_name: 'Blogs::User'
end

# and

# mingines/blogs/app/models/blogs/user.rb
class Blogs::User < ApplicationRecord
  has_many :blogs_articles, class_name: 'Blogs::Article', foreign_key: 'blogs_user_id', dependent: :destroy
end

Second - the paths

edit_blogs_article_path -> edit_article_path
new_blogs_article_path -> new_article_path
blogs_article_path -> article_path

edit_blogs_user_path -> edit_user_path
new_blogs_user_path -> new_user_path
blogs_user_path -> user_path

blogs_user_url -> user_url
blogs_users_url -> users_url
blogs_article_url -> article_url
blogs_articles_url -> articles_url

Now your paths should match: bin/rails routes

Third - params in controllers: param is missing or the value is empty: blogs_user

# mingines/blogs/app/controllers/blogs/articles_controller.rb
    # Only allow a list of trusted parameters through.
    def blogs_article_params
      params.require(:article).permit(:title, :body, :blogs_user_id)
      # params.require(:blogs_article).permit(:title, :body, :blogs_user_id)
    end
end

# and

# mingines/blogs/app/controllers/blogs/users_controller.rb
    # Only allow a list of trusted parameters through.
    def blogs_user_params
      params.require(:user).permit(:full_name, :email)
	    # params.require(:blogs_user).permit(:full_name, :email)
    end
end

run the migrations (given the config/application.rb update they run in place)

bin/rails db:migrate

NOTE: I use the trick to configure them to run in-place, since I haven’t set up the standard rails way to copy the migrations to the main app db/migrations folder - which would normally be done with:

bin/rails blogs:install:migrations
bin/rails db:migrate

now we can go to:

# first create a user
http://localhost:3000/users

# afterwards create an article
http://localhost:3000/articles

now we commit

git add .
git commit -m "add the blogs mingine"

Experience - Standard vs Modular

My colleague Jörg Jenni - created a little app messy vs modular - that need needs new features. It has 2 base Apps messy and modulariyed. Its a great way to experience the benefits of modular design.

My solution can be seen at: https://github.com/btihen/coupled_vs_modular

using JS within an mingine

using rspec within an mingine

using factories within an mingine

protected APIs within an mingine

using packwerk within an mingine

Further Ideas

For further improvements (protected namespaces, using packwerk to provide feedback and a list of design ideas from many others) see Rails with Protected Modules

Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature