Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 068 / 100

Rails engines: concepts, trade-offs and production lessons

Modular Rails applications.

Expona Admin7 min read

As Rails applications grow beyond a few thousand lines of code, the codebase often becomes difficult to navigate and reason about. Rails engines offer a powerful structural pattern to break monolithic applications into self-contained, reusable modules. In this article, we'll explore Rails engines: concepts, trade-offs and production lessons learned from teams who've successfully modularized their applications at scale.

What engines are — Mini Rails apps inside your app

A Rails engine is essentially a miniature Rails application that can be embedded within a parent Rails application. It's a gem that provides MVC components (models, controllers, views, migrations), routes, and assets as a cohesive package. Engines live in the lib/ directory when co-located with your main app (called "in-engine" development) or distributed as separate gems.

Think of engines as a bridge between monolithic architecture and full service extraction. They let you organize code into logical boundaries without the operational complexity of microservices. Each engine can have its own:

  • Models and database migrations
  • Controllers and routes
  • Views and assets
  • Tests (RSpec, Minitest)
  • Configuration and dependencies

The parent Rails app then mounts these engines, typically in config/routes.rb:

ruby
# config/routes.rb in main app
mount Admin::Engine => '/admin'
mount Billing::Engine => '/billing'

When mounted, the engine's routes become available under the specified path. Users navigate to /admin/dashboard or /billing/invoices as if they were part of the main app, but the code lives in isolation.

The engine gem structure looks like this:

ruby
admin_engine/
├── lib/
│   ├── admin_engine.rb
│   └── tasks/
├── app/
│   ├── controllers/
│   ├── models/
│   ├── views/
│   └── assets/
├── config/
│   └── routes.rb
├── db/
│   └── migrate/
├── spec/
├── Gemfile
└── admin_engine.gemspec

Use cases — Admin panels and shared features

The most common and safest use case for engines is isolating an admin panel. Admin interfaces are naturally separate from your core product logic, often requiring different permissions, styling, and dependencies. By extracting it into an engine, you reduce cognitive load and can deploy admin changes independently from customer-facing code.

Consider a SaaS application with a public product and an admin dashboard:

ruby
# engines/admin_dashboard/app/controllers/admin_dashboard/users_controller.rb
module AdminDashboard
  class UsersController < ApplicationController
    before_action :authenticate_admin!
    
    def index
      @users = User.all
    end
    
    def destroy
      User.find(params[:id]).destroy
      redirect_to admin_dashboard.users_path, notice: 'User deleted'
    end
  end
end

Another solid use case is shared features used across multiple applications. A notification engine, for example, could handle email, SMS, and push notifications. Instead of duplicating this logic in every app, you build it once as an engine and reuse it:

ruby
# Gemfile in main app
gem 'notifications_engine', path: '../notifications_engine'

# In your models
class User < ApplicationRecord
  include NotificationsEngine::Notifiable
end

# Sending a notification
user.notify(:password_reset, email: 'reset_link')

Reporting, multi-tenancy helpers, webhook handling, and feature flagging are other practical candidates. The key criterion: the feature should be functionally independent and have clear boundaries with the rest of your system.

Isolated namespaces — Avoid name clashes

As soon as you have multiple engines in one application, name collisions become inevitable. Without careful namespacing, your admin engine's User model might conflict with the main app's User model. This is where Rails' isolate_namespace directive comes in.

When you generate an engine with isolate_namespace enabled, all models, controllers, and routes are scoped to that engine:

ruby
# engines/admin_dashboard/lib/admin_dashboard/engine.rb
module AdminDashboard
  class Engine < ::Rails::Engine
    isolate_namespace AdminDashboard
  end
end

With isolation, your admin models live in AdminDashboard::User, controllers in AdminDashboard::UsersController, and routes are prefixed:

ruby
# In the isolated engine's config/routes.rb
AdminDashboard::Engine.routes.draw do
  resources :users
end

# Generated route helpers: admin_dashboard.users_path
# Generated controllers: AdminDashboard::UsersController

Isolation also prevents the engine from polluting the parent app's asset pipeline. Each engine can have its own stylesheets and JavaScript without worrying about conflicts. However, isolation comes with a trade-off: the engine cannot easily access the parent app's models without explicit coupling.

If your admin engine needs to reference the main app's User model, you must either:

  1. Import the parent class: ::User (using the leading :: to skip the namespace)
  2. Define a dependency: Pass the parent's model class to the engine via configuration
  3. Use a shared gem: Extract common models into a separate gem that both depend on
ruby
# Option 1: Direct reference to parent class
module AdminDashboard
  class Dashboard
    def active_users
      ::User.where(active: true)
    end
  end
end

# Option 2: Configuration
# config/initializers/admin_dashboard.rb
AdminDashboard.configure do |config|
  config.user_class = ::User
end

# engines/admin_dashboard/app/models/admin_dashboard/user_decorator.rb
module AdminDashboard
  class UserDecorator
    def initialize(user = nil)
      @user = user || AdminDashboard.config.user_class.new
    end
  end
end

Packwerk — Enforce boundaries in large monoliths

Once you have multiple engines or packages, maintaining clear architectural boundaries becomes difficult. Developers might import private constants from other packages, creating hidden dependencies. Over time, your codebase becomes a tangled web where changes ripple unpredictably. This is where Packwerk, Shopify's open-source gem, comes in.

Packwerk lets you define packages (a formalized version of engines) and enforce strict dependency rules. Each package declares what it exposes publicly and what it keeps private. Violations are caught in CI.

Setting up Packwerk:

ruby
# Gemfile
gem 'packwerk'

# Run the initializer
bundle exec packwerk setup

This generates a package.yml at the root and in each package directory:

ruby
# package.yml (root)
# Defines the main app package

# engines/admin_dashboard/package.yml
enforce_dependencies: true
enforce_privacy: true
public_path: app/
dependencies:
  - '.'  # Depends on root package
  - engines/reporting

Any constant defined in app/ or lib/ outside the public path is considered private. Attempting to reference it from another package causes Packwerk to fail:

ruby
# engines/admin_dashboard/app/models/admin_dashboard/internal_helper.rb
module AdminDashboard
  class InternalHelper  # Private—not in public_path
    def self.sensitive_calculation
      # ...
    end
  end
end

# engines/reporting/app/services/reporting_service.rb
class ReportingService
  def build_report
    AdminDashboard::InternalHelper.sensitive_calculation  # ❌ Packwerk fails here
  end
end

In a production monolith with 5+ packages, Packwerk becomes invaluable. It documents your architecture and prevents accidental violations during rapid development.

Common mistakes

  • Overengineering small projects: Don't create engines for features that will never be reused. The overhead of managing routes, migrations, and dependencies isn't worth it for a simple admin page in a small app. Start monolithic; extract engines only when you have clear pain points.
  • Circular dependencies: Engine A depends on Engine B, which depends on Engine A. This creates subtle bugs and makes testing harder. Use Packwerk or a dependency graph tool to catch these early.
  • Sharing databases without clear contracts: When two engines modify the same table, schema migrations become fragile. One engine's migration might break another's assumptions. Define clear ownership: one engine owns the schema, others access it through APIs or read-only views.
  • Ignoring migration ordering: Engines can have migrations, but if Engine A's migration depends on Engine B's table and runs first, you'll get errors. Document migration dependencies or consolidate schema ownership in the main app.
  • Tight coupling through shared fixtures: Engine tests that depend on factory-created records from the parent app create brittle tests. Each engine should manage its own test data.

Key takeaways

  • Rails engines are mini-applications embedded in your main app, ideal for admin panels, reporting, and truly independent features.
  • Use isolate_namespace to prevent naming collisions, but understand the coupling trade-offs this creates.
  • Start with co-located engines (lib/) for faster iteration; convert to gems only when you need independent versioning or distribution.
  • Packwerk enforces architectural boundaries at scale, preventing circular dependencies and hidden couplings in large monoliths.
  • Engines add complexity—extract them only when you have clear organizational or reusability wins, not for every feature.
  • Own the database schema in one place; other engines should access it through well-defined APIs or as read-only consumers.
  • Test engines in isolation; avoid dependencies on parent app factories or fixtures to keep tests portable and maintainable.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles