Architecture

How to Extract an Engine from a Rails Monolith

BLUF (Bottom Line Up Front): As a legacy Rails monolith grows, namespaces collapse and domain boundaries blur. The architectural fix is extracting distinct bounded contexts (e.g., billing, reporting) into mountable Rails Engines. This enforces strict isolation, separate routing, and independent test suites without moving to a full microservices architecture.

Phase 1: The Tangled Monolith

In a mature application, models from completely different business domains often call each other directly, creating a web of dependencies that makes safe refactoring impossible.

Synthetic Engineering Context: Boundary Violations

Consider a monolith where the Invoice model directly accesses the User model’s internal authentication logic to determine discount tiers.

# The Bad Code: Boundary Violation
class Invoice < ApplicationRecord
  def calculate_discount
    # The billing domain should not know about authentication mechanisms
    user.last_login_at < 30.days.ago ? 0.10 : 0.0
  end
end

Extracting the billing logic into an engine forces you to define a clear API between the core application and the billing module.

Phase 2: Engine Extraction

Generating a mountable engine creates an isolated directory structure with its own app, config, and lib folders.

Execution: Generating the Engine

Use the Rails generator to create the isolated engine structure.

$ bin/rails plugin new components/billing --mountable

This creates a components/billing directory. You must then mount this engine in your main application’s router.

# config/routes.rb (Main App)
Rails.application.routes.draw do
  mount Billing::Engine => "/billing"
end

Execution: Isolating Dependencies

Move the relevant models, controllers, and views into the new engine. The engine must declare its own dependencies in its .gemspec file, not the main Gemfile.

# components/billing/billing.gemspec
$:.push File.expand_path("lib", __dir__)
require "billing/version"

Gem::Specification.new do |s|
  s.name        = "billing"
  s.version     = Billing::VERSION
  
  # Declare engine-specific dependencies here
  s.add_dependency "rails", ">= 6.1.0"
  s.add_dependency "stripe", "~> 7.0"
end

Phase 3: Next Steps & Risk Mitigation

Extracting an engine does not automatically fix bad code. If your core application and the new engine share the exact same database tables without clear ownership, you have simply created a distributed monolith. You must establish strict database boundaries.

Need Help Stabilizing Your Legacy App? We architect modular monoliths for scaling enterprises. Our team at USEO untangles complex domain logic and extracts robust Rails Engines.

Contact us for a Technical Debt Audit