Rails Engines: Build Modular Applications
Learn how Rails Engines work, from isolated and mountable engines to migrations, testing, and when modular Rails is worth it.
By Jean Emmanuel Cadet • 25 min read
Every Rails application starts small and tidy. Then features arrive. Billing gets bolted on, an admin area appears, reporting needs its own models and views, and one day app/models holds a hundred files with no obvious boundaries. Changing one feature starts to feel risky because you can’t tell what else depends on it.
Modular architecture is the usual answer. You group related code into components with clear responsibilities and explicit points of contact. Rails Engines are the framework’s built-in way to do this. An engine is a miniature Rails application, with its own models, controllers, views, routes, and migrations, that plugs into a larger host application. Because an engine can be packaged as a gem, the same component can also be shared across several Rails projects.
Good candidates for extraction include billing, authentication, an administration panel, a reporting dashboard, a help center, or a notification system. Each is a feature with its own data and screens, and each can be understood without knowing the rest of the app.
In this guide you will learn:
- what Rails Engines are and how they differ from Rails applications, plugins, and Ruby gems
- the difference between regular engines and isolated (mountable) engines
- how to generate, structure, mount, configure, and test an engine
- how to handle migrations and share data with the host app safely
- when engines are worth their cost, and when they are not
All examples use a small Billing engine and follow Rails 8.1 conventions.
What Are Rails Engines?
A Rails Engine is a packaged slice of a Rails application. It can contain nearly everything a full app contains: models, controllers, views, helpers, routes, migrations, jobs, mailers, assets, initializers, and tests. The difference is that an engine is not meant to run alone in production. It is designed to be loaded into a host application, which provides the surrounding environment: the database connection, the web server, authentication, and deployment.
You may be surprised to learn that you already use an engine every day. Rails::Application is a subclass of Rails::Engine, so your application is technically the top-level engine. Popular gems such as Devise, Active Storage’s admin-style tooling in third-party packages, and many admin dashboards are also engines. When you install them, their controllers, views, and routes become part of your app.
Engines sit in a small hierarchy of Rails extension points:
Rails::Railtieis the base class for hooking into the framework (initializers, rake tasks, configuration). It has no application code of its own.Rails::Engineinherits fromRailtieand adds the application directory structure, routes, and autoloading.Rails::Applicationinherits fromEngineand adds the things only one component in a process should own, such as the middleware stack and the boot sequence.
What about plugins? In early Rails versions, a “plugin” was a folder under vendor/plugins that extended an app. That mechanism was deprecated in Rails 3.2 and removed in Rails 4. Today the word survives mostly in the generator name, rails plugin new, which actually creates a gem skeleton and can produce an engine. When a modern developer says “Rails plugin,” they usually mean a gem that extends Rails, often an engine or a Railtie.
Finally, an engine is easy to package and share as a gem. A gem that contains a Rails::Engine subclass gets its app/ directories added to the host’s autoload paths and its routes made available for mounting, simply by being in the Gemfile. You can also keep an engine inside your repository and reference it by path, which is how most teams use engines for modularity.
Rails Engines vs. Rails Applications vs. Ruby Gems
The three terms overlap, so it helps to separate their purposes.
A Rails application is the deployable unit. It owns the database configuration, the environment settings, the credentials, the middleware stack, and the top-level routes. There is exactly one per running process.
A Ruby gem is a packaged library of Ruby code with a gemspec, a version, and dependencies. Gems have no required structure beyond lib/. A gem might provide a money-formatting helper, an API client, or a parsing library, and it does not need Rails at all.
A Rails Engine is Rails-aware code with an application-like structure. It can ship controllers, views, routes, and migrations, which a plain gem has no standard way of integrating. An engine is usually distributed as a gem, so the two categories are not exclusive: every engine gem is a gem, but most gems are not engines.
Rails application | Rails Engine | Plain Ruby gem | |
|---|---|---|---|
Purpose | Deployable product | Reusable Rails feature or module | Reusable Ruby code |
Runs on its own in production | Yes | No (needs a host) | No |
Has models, views, routes | Yes | Yes | Not by convention |
Owns database config and credentials | Yes | No (uses the host’s) | No |
Typical packaging | Git repository | Gem or in-repo path | Gem |
Typical example | Your SaaS product | Billing, admin panel, help center | API client, formatter |
A useful rule of thumb: if the code needs to know about HTTP requests, views, routes, or Active Record tables, an engine is a natural fit. If it is pure Ruby logic (a calculation, a parser, a client for a third-party API), a plain gem or even a well-named plain Ruby class in your app is enough.
When an engine is unnecessary. If a feature is only used in one app, is owned by one team, and is not hurting you, a clean folder structure with namespaces inside app/ may be all you need. Service objects, concerns, and well-named modules handle many organizational problems without any engine machinery. If you are curious about that approach, see our guide to Rails service objects.
Types of Rails Engines: Regular vs. Isolated (Mountable) Engines
Not every engine behaves the same way. The key difference is whether the engine is isolated.
Regular (non-isolated) engines
A regular engine shares the host application’s namespace. Its models and controllers are top-level constants (Invoice, InvoicesController), its routes are drawn into the host’s route set, and its helpers and tables are not prefixed. This is convenient, but name collisions are easy. If the host also has an Invoice model, you have a conflict.
You can generate this style with bin/rails plugin new billing --full.
Isolated engines
An isolated engine calls isolate_namespace in its engine class. This tells Rails to keep the engine’s constants, routes, and helpers inside its own module:
# lib/billing/engine.rb
module Billing
class Engine < ::Rails::Engine
isolate_namespace Billing
end
end
Here is what isolate_namespace Billing changes:
- Models:
Billing::Invoiceuses the table namebilling_invoices, because the module gets atable_name_prefixofbilling_. Model naming also becomes relative inside the engine, so form params and route keys areinvoiceandinvoicesrather thanbilling_invoice. - Controllers: engine controllers live under
Billing::and inherit fromBilling::ApplicationController, not the host’sApplicationController. - Routes: the engine gets its own route set,
Billing::Engine.routes, separate from the host’s routes. Its URL helpers do not leak into the host, and the host’s helpers do not leak into the engine. - Helpers: the engine’s controllers and views use the engine’s helpers, not the host’s.
Isolation is about naming and routing, not about the database or the Ruby process. The engine still shares the host’s database, and Ruby constants are still global objects. If something in the engine says ::User, it reaches the host’s User.
What “mountable” means
The word mountable causes confusion. The --mountable option of the plugin generator creates an engine that is isolated and set up to be mounted at a path. In casual conversation, “mountable engine” and “isolated engine” are often used interchangeably, and in practice they usually describe the same generated result. Strictly, though, they are two ideas: isolation (isolate_namespace) is how the engine’s code is namespaced, and mounting (mount in routes) is how the host exposes the engine’s routes. This guide uses “isolated engine” for the first idea and “mounted” for the second.
For nearly all modularity work, you want an isolated engine.
Creating Your First Rails Engine
Let’s build a Billing engine. Many teams keep in-repo engines under an engines/ directory in the host app. From the host app’s root:
bin/rails plugin new engines/billing --mountable
If you are making a standalone gem outside any host app, run rails plugin new billing --mountable in a fresh directory instead.
What --mountable creates
The --mountable option generates a gem skeleton that includes:
- an engine class with
isolate_namespacealready applied - namespaced base classes (
Billing::ApplicationController,Billing::ApplicationRecord, and similar) - an engine-specific layout and stylesheet
- an engine route file using
Billing::Engine.routes.draw - a small dummy application under
test/dummythat mounts the engine so you can run and test it alone
The exact files can differ slightly between Rails versions, but the result looks roughly like this:
billing/
├── Gemfile
├── MIT-LICENSE
├── README.md
├── Rakefile
├── billing.gemspec
├── app/
│ ├── assets/stylesheets/billing/application.css
│ ├── controllers/billing/application_controller.rb
│ ├── helpers/billing/application_helper.rb
│ ├── jobs/billing/application_job.rb
│ ├── mailers/billing/application_mailer.rb
│ ├── models/billing/application_record.rb
│ └── views/layouts/billing/application.html.erb
├── bin/rails
├── config/routes.rb
├── lib/
│ ├── billing.rb
│ ├── billing/engine.rb
│ ├── billing/version.rb
│ └── tasks/billing_tasks.rake
└── test/
├── test_helper.rb
├── integration/
└── dummy/
Keep names consistent
Consistency is the most important habit with engines. The gem name (billing), the Ruby module (Billing), the directory namespaces (app/models/billing/), and the mount path (/billing) should all agree. When you need a multi-word name, follow Ruby conventions: a gem named help_center uses the module HelpCenter and directories named help_center/.
Before you bundle it
Open billing.gemspec and replace the TODO placeholders for the summary, description, and homepage. Bundler validates gemspecs, and placeholder values can cause load errors when you reference the engine from a host app.
Running and testing the engine alone
Because the engine ships with a dummy app, you can work on it independently. From the engine’s directory:
bin/rails server # boots the dummy app with the engine mounted at /billing
bin/rails console # console with the dummy app loaded
bin/rails test # runs the engine's test suite
This independence is one of the biggest benefits of an engine. You can develop and test it without booting your entire product.
Understanding the Rails Engine Structure
Each folder in an engine has the same role as in a regular Rails app:
app/modelsholds Active Record models and plain Ruby model classes, namespaced under the engine (app/models/billing/invoice.rb).app/controllersholds the engine’s controllers (app/controllers/billing/invoices_controller.rb).app/viewsholds templates. Isolated engines namespace their view folders too (app/views/billing/invoices/index.html.erb) and have their own layout underapp/views/layouts/billing/.app/helpersholds view helpers, namespaced the same way.config/routes.rbdefines the engine’s routes onBilling::Engine.routes.lib/billing/engine.rbdefines the engine class. This is whereisolate_namespacelives, along with engine-level initializers and configuration.lib/billing.rbis the gem’s entry point. It requires the engine and holds any shared module-level code.test/(orspec/if you choose RSpec) holds tests, plustest/dummy, the throwaway host app.
Autoloading and Zeitwerk
Rails uses Zeitwerk to load constants on demand, and it treats each engine’s app/* directories as autoload roots. The file path below the root determines the constant name. Engines make this rule especially important, because the namespace directory is what keeps constants apart. For a deeper look at how this works, read our article on Rails autoloading with Zeitwerk.
Here is how paths map to constants in our engine:
File path | Constant |
|---|---|
|
|
|
|
|
|
|
|
The concerns row is worth studying. app/models/concerns is itself an autoload root, so the word concerns is not part of the constant name. The path below that root, billing/billable.rb, becomes Billing::Billable.
One more detail: lib/ is not an autoload root by default. Files such as lib/billing/configuration.rb are loaded with an explicit require, usually from lib/billing.rb. Put code that should autoload and reload in app/, and code that must be available at boot in lib/.
Mounting a Rails Engine in a Host Application
Creating an engine is half the story. The host application has to load it and expose its routes.
Step 1: Add the engine to the Gemfile
For an engine that lives inside the host repository, reference it by path:
# Gemfile (host application)
gem "billing", path: "engines/billing"
Then run bundle install. Once the gem is loaded, the engine’s app/ directories join the host’s autoload paths and its initializers run.
Step 2: Mount it in the routes
# config/routes.rb (host application)
Rails.application.routes.draw do
root "home#index"
mount Billing::Engine, at: "/billing"
end
The at: option sets the path prefix. A request to /billing/invoices is handed to the engine, which sees the path as /invoices and matches it against its own routes. For a refresher on how the router dispatches requests, see our complete guide to how Rails routing works.
Step 3: Link between the host and the engine
Mounting also creates a route proxy named after the engine. From the host, use that proxy to reach engine routes:
<%= link_to "My invoices", billing.invoices_path %>
Going the other way, engine views reach host routes through main_app:
<%= link_to "Back to the app", main_app.root_path %>
If you forget main_app inside an isolated engine, Rails looks for the route in the engine’s own route set and raises a NoMethodError. This is isolation doing its job.
Route names and conflicts
You can customize the proxy name with as::
mount Billing::Engine, at: "/billing", as: "billing_engine"
You then call billing_engine.invoices_path. Be careful when mounting several engines: two mounts using the same as: name collide, and overlapping paths are matched in the order they are declared. Running bin/rails routes lists mounted engines and their routes in separate sections, which makes conflicts easy to spot.
Mounting vs. adding a gem
Adding a gem to the Gemfile loads its code. For a plain gem, that is the whole story. For an engine, loading makes its models, helpers, and migrations available, but its web interface is still unreachable until you mount it. Mounting is the explicit decision to expose the engine’s routes at a path of your choosing.
Models, Controllers, and Routes Inside Rails Engines
Let’s give the engine something to do: let a signed-in owner list and view their invoices.
Routes
# engines/billing/config/routes.rb
Billing::Engine.routes.draw do
resources :invoices, only: %i[index show]
root to: "invoices#index"
end
These routes are scoped to the engine. Because of the mount path, invoices#index is reachable at /billing/invoices in the host, and the route helper is invoices_path inside the engine or billing.invoices_path outside it.
A model
Generate the model from inside the engine directory so files land in the right namespace:
cd engines/billing
bin/rails generate model invoice number:string total_cents:integer currency:string status:string "owner:references{polymorphic}"
Then tighten the generated migration and model:
# engines/billing/db/migrate/20261105000000_create_billing_invoices.rb
class CreateBillingInvoices < ActiveRecord::Migration[8.1]
def change
create_table :billing_invoices do |t|
t.string :number, null: false
t.integer :total_cents, null: false, default: 0
t.string :currency, null: false
t.string :status, null: false, default: "draft"
t.references :owner, polymorphic: true, null: false
t.timestamps
end
add_index :billing_invoices, :number, unique: true
end
end
# engines/billing/app/models/billing/invoice.rb
module Billing
class Invoice < ApplicationRecord
belongs_to :owner, polymorphic: true
enum :status, { draft: "draft", issued: "issued", paid: "paid" }, default: "draft"
validates :number, presence: true, uniqueness: true
validates :currency, presence: true
validates :total_cents, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
end
Notice the table name billing_invoices. That prefix comes from isolate_namespace, and it keeps the engine’s tables from colliding with a host table named invoices. Notice also that the model stores an owner polymorphically instead of a hard user_id. We will explain why in the next section.
A controller
# engines/billing/app/controllers/billing/invoices_controller.rb
module Billing
class InvoicesController < ApplicationController
def index
@invoices = owned_invoices.order(created_at: :desc)
end
def show
@invoice = owned_invoices.find(params[:id])
end
private
def owned_invoices
Invoice.where(owner: current_owner)
end
end
end
Write the namespace with nested module Billing blocks, as above, rather than class Billing::InvoicesController. The nested form lets Ruby resolve Invoice to Billing::Invoice and ApplicationController to Billing::ApplicationController through lexical scope. The compact form does not, and it can silently pick up a host constant with the same name.
A view
<%# engines/billing/app/views/billing/invoices/index.html.erb %>
<h1>Invoices</h1>
<ul>
<% @invoices.each do |invoice| %>
<li><%= link_to invoice.number, invoice_path(invoice) %> (<%= invoice.status %>)</li>
<% end %>
</ul>
<%= link_to "Back to the app", main_app.root_path %>
Why namespace isolation matters
Suppose your host has a Report model and a separate reporting engine also defines Report. Without isolation, both would claim the same top-level constant and the same table name. With isolation they become ::Report and Reporting::Report, backed by reports and reporting_reports. The same logic protects controllers, helpers, and route names when you add a second or third engine.
Using engine classes from the host
The host refers to engine classes by their full names:
Billing::Invoice.where(owner: current_user).issued
Keep this usage narrow. The more places in the host that reach into Billing:: internals, the less modular the system really is. A better habit is to let the engine expose a small public surface (a few well-named classes or methods) and treat everything else as private.
Sharing Data Between an Engine and the Host Application
The hardest design question with an engine is not how to build it, but how it should talk to the host. A billing engine needs to know who is being billed. Where should that knowledge live?
Option 1: Tight coupling
The engine could directly reference User, call current_user, and join against the users table. This works and is quick to write, but the engine is now useless in any app without a User model, and every change to the host’s user model can break the engine. At that point you have a folder with extra ceremony, not a modular component.
Option 2: Explicit integration points
The engine declares what it needs, and the host supplies it. Three techniques work well together.
Polymorphic ownership. Instead of belongs_to :user, the engine stores owner_type and owner_id. Any host model, whether User, Account, or Organization, can be an owner. The trade-off is that the database cannot enforce a foreign key on a polymorphic association, so the engine must validate and scope carefully.
A concern the host includes. The engine publishes a concern that adds the association to whichever host model owns invoices. If you want a refresher on this pattern, see our article on Rails concerns.
# engines/billing/app/models/concerns/billing/billable.rb
module Billing
module Billable
extend ActiveSupport::Concern
included do
has_many :billing_invoices,
class_name: "Billing::Invoice",
as: :owner,
dependent: :restrict_with_error
end
end
end
# app/models/user.rb (host)
class User < ApplicationRecord
include Billing::Billable
end
Now user.billing_invoices works, and the engine never mentions User.
Configuration. The engine asks the host, through configuration, how to find the current owner and which controller to inherit from. That is covered in the configuration section below.
Engine-owned data vs. host-owned data
A healthy rule is that each table has exactly one owner. The engine owns billing_invoices; the host owns users. The engine should not add columns to the host’s tables, and the host should not write directly to the engine’s tables through raw SQL. If the host needs to create an invoice, it should call an engine method, for example through a service object such as Billing::IssueInvoice.call(owner:, total_cents:). This keeps validation and business rules in one place.
Security and authorization
Accessing host data from an engine raises real security questions. Keep these in mind:
- Scope every query to the current owner. Notice that
owned_invoicesfilters bycurrent_owner. CallingInvoice.find(params[:id])would let any signed-in person read any invoice by guessing IDs. - Do not assume authentication exists. Fail closed. If the engine cannot identify an owner, it should deny the request rather than show everything.
- Decide who authorizes. Authentication usually belongs to the host. Authorization rules specific to billing (for example, “only account admins can see invoices”) can live in the engine, but they should rely on host-supplied identity.
- Remember CSRF and sessions. Engine controllers that inherit from
ActionController::Baseget CSRF protection by default, but they do not automatically share host-controller behavior such as before-actions unless you configure the parent controller.
Rails Engine Migrations and Database Integration
Engines own their schema, but the migrations ultimately run in the host’s database.
Installing engine migrations
An engine’s migration files live in the engine’s db/migrate directory. To bring them into the host, run the generated install task from the host app:
bin/rails billing:install:migrations
bin/rails db:migrate
The first command copies each migration into the host’s db/migrate, giving the copy a fresh timestamp and a suffix naming the engine (for example 20261105120000_create_billing_invoices.billing.rb). The second command runs them like any other migration. You can also use the generic form, bin/rails railties:install:migrations FROM=billing, to copy migrations from the named engine.
Where migrations actually run
Always in the host application’s database, against the host’s schema.rb or structure.sql. The engine’s own db/migrate folder is only used to develop and test the engine, where the dummy app plays the host.
Ownership, naming, and compatibility
- Prefixes prevent collisions. Table names such as
billing_invoicescome fromisolate_namespace. Index and constraint names should stay unique too, and Rails derives them from table names by default. - Timestamps are regenerated. The copy step re-stamps each migration, so applying engine upgrades later means installing only the new migrations. Re-running the task skips ones that were already copied.
- Class names are unique. If two copied migrations share a class name, Rails raises a duplicate migration error. Descriptive names such as
CreateBillingInvoicesavoid this. - Match the migration version. Generated engine migrations should declare a version such as
ActiveRecord::Migration[8.1]that the host supports.
Why you should review them first
Treat engine migrations like any other code you deploy. A copied migration can lock a large table, add a non-null column without a default, or drop data. Read each file before applying it in production, and apply it in staging first. For general guidance, see our article on Rails database migration best practices.
Running engine migrations without copying them
Some teams prefer not to copy migrations at all, and instead have the engine append its migration path to the host’s. The Rails guides document this approach:
# engines/billing/lib/billing/engine.rb
module Billing
class Engine < ::Rails::Engine
isolate_namespace Billing
initializer "billing.append_migrations" do |app|
unless app.root.to_s.match?(root.to_s)
config.paths["db/migrate"].expanded.each do |expanded_path|
app.config.paths["db/migrate"] << expanded_path
end
end
end
end
end
This removes the copy step, but the trade-off is that the host no longer has the migration files in its own repository, so reviewing and tracking schema history gets less visible. Pick one approach per engine and document it in the engine’s README.
Configuring and Customizing Rails Engines
A reusable engine should not hardcode host details such as a currency, a parent controller, or an API key. It should expose configuration and let each host decide.
Define a configuration object
# engines/billing/lib/billing/configuration.rb
module Billing
class Configuration
attr_accessor :parent_controller, :current_owner, :default_currency, :api_key
def initialize
@parent_controller = "ActionController::Base"
@current_owner = ->(_controller) { nil }
@default_currency = "USD"
@api_key = nil
end
end
end
# engines/billing/lib/billing.rb
require "billing/version"
require "billing/configuration"
require "billing/engine"
module Billing
class << self
def config
@config ||= Configuration.new
end
def configure
yield config
end
end
end
Using a string for parent_controller matters. Host classes may not be loadable while initializers run, so you store the name and call constantize later, once the app is ready.
Use the configuration inside the engine
# engines/billing/app/controllers/billing/application_controller.rb
module Billing
class ApplicationController < Billing.config.parent_controller.constantize
before_action :require_owner!
private
def current_owner
Billing.config.current_owner.call(self)
end
def require_owner!
head :forbidden unless current_owner
end
end
end
The default denies access when no owner can be found, which is the safe failure mode.
Configure it from the host
# config/initializers/billing.rb (host)
Billing.configure do |config|
config.parent_controller = "ApplicationController"
config.current_owner = ->(_controller) { Current.user }
config.default_currency = "USD"
config.api_key = Rails.application.credentials.dig(:billing, :api_key)
end
This host uses the Current.user pattern generated by Rails’ built-in authentication, but any lookup works. The engine does not care how the host authenticates, only that it receives an owner.
Avoid host-specific assumptions
Search your engine for hardcoded references to User, current_user, root_path, host table names, or environment variables. Each is a hidden assumption. Replace them with configuration, a concern, or main_app route helpers.
Environments and credentials
Keep secrets in the host’s credentials, as shown above, not inside the engine’s repository. Rails supports per-environment credentials files, so production and staging can use different keys without any engine code changes. Avoid reading ENV or credentials directly inside engine models; read them in the host initializer and pass them through configuration. For required settings, consider raising a clear error at boot when a value is missing in production, rather than failing on the first request.
Testing Rails Engines
An engine needs two layers of tests: ones that prove it works alone, and ones that prove it works inside a real host.
Testing the engine independently
The dummy app lets the engine’s suite run without the host. Because our engine stores polymorphic owners, create a simple owner model in the dummy app:
cd engines/billing/test/dummy
bin/rails generate model User email:string
cd ../..
bin/rails db:migrate
Running bin/rails db:migrate from the engine root applies both the engine’s migrations and the dummy app’s.
Model test:
# engines/billing/test/models/billing/invoice_test.rb
require "test_helper"
module Billing
class InvoiceTest < ActiveSupport::TestCase
setup do
@owner = User.create!(email: "[email protected]")
end
test "is valid with a number, currency, and owner" do
invoice = Invoice.new(number: "INV-1", currency: "USD", total_cents: 1000, owner: @owner)
assert invoice.valid?
end
test "requires a number" do
invoice = Invoice.new(currency: "USD", owner: @owner)
assert_not invoice.valid?
assert_includes invoice.errors[:number], "can't be blank"
end
end
end
Integration test for routes, scoping, and authorization. Because the dummy app mounts the engine at /billing, requests go through the real mounted route:
# engines/billing/test/integration/billing/invoices_test.rb
require "test_helper"
module Billing
class InvoicesTest < ActionDispatch::IntegrationTest
setup do
@owner = User.create!(email: "[email protected]")
@other = User.create!(email: "[email protected]")
@mine = Invoice.create!(number: "INV-1", currency: "USD", owner: @owner)
@theirs = Invoice.create!(number: "INV-2", currency: "USD", owner: @other)
Billing.config.current_owner = ->(_controller) { @owner }
end
teardown do
Billing.config.current_owner = ->(_controller) { nil }
end
test "lists only the current owner's invoices" do
get "/billing/invoices"
assert_response :success
assert_includes response.body, "INV-1"
assert_not_includes response.body, "INV-2"
end
test "cannot view another owner's invoice" do
get "/billing/invoices/#{@theirs.id}"
assert_response :not_found
end
end
end
The second test checks the authorization boundary directly, which is the kind of test that catches the most serious engine bugs. (In the test environment, Rails renders a 404 for a missing record rather than raising.) Resetting the configuration in teardown keeps tests independent.
Testing inside the host
Passing tests in the dummy app do not prove the engine works in your real application. The host has its own authentication, middleware, layout, and models, and any of them can interact badly with the engine. Add a few integration tests in the host app that cover the actual wiring:
# test/integration/billing_engine_test.rb (host)
require "test_helper"
class BillingEngineTest < ActionDispatch::IntegrationTest
test "billing requires a signed-in user" do
get billing.invoices_path
assert_redirected_to new_session_path
end
test "a signed-in user sees their invoices" do
user = users(:one)
sign_in_as(user)
Billing::Invoice.create!(number: "INV-100", currency: "USD", owner: user)
get billing.invoices_path
assert_response :success
assert_includes response.body, "INV-100"
end
end
This example assumes the host uses Rails’ generated authentication, including its sign_in_as test helper. Adjust the sign-in line to match your own setup.
Testing integration points
Test the seams where the engine meets the host: the concern (user.billing_invoices), the configuration lambdas, and any service object the host calls to create invoices. Those seams are where upgrades usually break things.
Test independently so failures point at the engine itself, and test in the host so you know the pieces fit together.
When Should You Use Rails Engines?
Engines solve specific problems. They are worth considering when:
- A large application has clearly separated domains. If billing, support, and reporting rarely touch each other’s internals, engines can make those boundaries real instead of aspirational.
- Functionality is shared across multiple Rails apps. One billing engine installed in three products beats three diverging copies.
- You are building a modular monolith. Engines are one way to keep a single deployable app while enforcing module boundaries inside it.
- Separate teams maintain separate features. Distinct engines with narrow public interfaces reduce merge conflicts and accidental coupling.
- A feature might eventually be extracted. An isolated engine is a stepping stone toward a separate gem or service, because its dependencies are already explicit.
When something simpler is enough
If one team owns a single app and the code is simply messy, start with namespaces, concerns, and service objects. If you need reusable Ruby logic with no Rails integration, a plain gem is lighter than an engine. And if a feature is small, an engine adds more ceremony than value.
The cost of engines
Engines add real complexity: more directories, a second test setup, migration copying, route and constant namespacing, configuration APIs to design, and versioning if you publish a gem. They also introduce a tempting trap, which is that the folder boundary looks like a design boundary even when the code still reaches across it freely. Engines help only if you also keep the dependencies disciplined. They are not a default for every Rails project, and many applications never need one.
Common Mistakes and Best Practices
Creating an engine too early. Extracting before a boundary is clear leads to the wrong boundary. Wait until the feature has stable edges, or until you have a concrete reuse need.
Using unclear or inconsistent namespaces. Keep the gem name, module name, directory names, and mount path aligned. Always call isolate_namespace for engines you plan to mount.
Coupling too tightly to the host. Avoid referencing User, current_user, or host tables directly. Use polymorphic associations, concerns, and configuration instead.
Creating route or constant conflicts. Give each mount a unique as: name and path. Use nested module blocks so constants resolve inside the engine namespace, and use main_app for host routes.
Forgetting to install migrations. After adding or upgrading an engine, run its install task and review the copied files. A missing table shows up as a confusing runtime error.
Assuming things about host models. Do not assume the host has a particular column or method. Document the required interface, and prefer a concern that adds what the engine needs.
Skipping host-context tests. Dummy-app tests are necessary but not sufficient. Add a few integration tests in the host.
Hardcoding configuration. Currencies, API keys, parent controllers, and feature flags belong in the host’s initializer and credentials, not in engine source files.
Ignoring authentication and authorization boundaries. Scope every query to the current owner, deny by default, and write tests for cross-account access.
A practical checklist
- Start with one small engine that has a clear purpose.
- Keep the public surface tiny and document it in the README.
- Let each table have a single owner.
- Prefer explicit integration points over reaching into the other side.
- Run the engine’s suite alone and inside the host in CI.
- Review migrations and upgrade notes before deploying a new engine version.
Conclusion: Rails Engines for Modular Rails Applications
Rails Engines let you split a growing application into self-contained components with their own models, controllers, views, routes, and migrations. An isolated engine, created with isolate_namespace and commonly generated with --mountable, keeps constants, tables, and routes from colliding with the host. Mounting it at a path decides where its routes appear, and configuration, concerns, and polymorphic associations decide how it integrates with the rest of your app.
Keep the distinctions in mind. A Rails application is the deployable product. A Ruby gem packages reusable Ruby code. A Rails Engine packages reusable Rails features and is usually distributed as a gem. The old “plugin” idea has largely been replaced by gems and engines.
Choose engines when there is a clear architectural or reuse benefit, such as separated domains, shared functionality, or independent teams. When there is not, simpler organization is the better choice.
If modularization is justified for your project, start small. Pick one well-defined feature, generate an isolated engine, give it a narrow interface, and test it both alone and inside your app. You will learn more from that first engine than from any amount of planning.