Skip to main content

Modular Monoliths in Rails 8.1 with Engines: Boundaries Without Microservices

How to carve a big Rails app into modules that deploy together, without letting dependencies leak

A production-minded guide to building a modular monolith in Rails using Engines: where boundaries usually leak, how to structure code, and the upgrade-safe patterns that prevent cross-domain coupling.

A
Raza Hussain
· Updated: · 6 min read · 106
Published in Ruby & Rails Core
Modular Monoliths in Rails 8.1 with Engines: Boundaries Without Microservices

Start from a real production bug

Your app is “one Rails app”… until it isn’t.

A real failure mode looks like this:

  • Team A adds a Billing::Invoice column and a data migration.
  • Team B deploys a change in a different area that loads invoices indirectly.
  • In production, requests start failing with PG::UndefinedColumnbut only on some pods.

The root cause wasn’t Postgres.

It was deployment order + boundary leakage:

  • one part of the app shipped code that now expects schema changes
  • another part of the app is still running old schema or old code
  • and because everything can reference everything, the “blast radius” is the whole monolith

This is the pain modular monoliths try to solve: keep one deployable unit, but reduce accidental coupling.


The wrong mental model

“An Engine is just a folder with routes.”

No.

An Engine is a packaging unit with its own:

  • namespaces
  • routes
  • autoloading boundaries
  • initializers
  • migrations (optionally)
  • and, most importantly, a place to enforce what other code is allowed to touch

If you treat engines as “folders”, you’ll still have:

  • cross-domain model access
  • shared tables used inconsistently
  • “just call that service object” from anywhere
  • and a monolith that’s modular only on paper

Why the naive approach fails

Naive approach #1: Move code into an engine but keep direct model access

You create:

  • engines/billing
  • engines/catalog

…and then in Catalog you do:

# engines/catalog/app/services/catalog/recommendations.rb
Billing::Invoice.where(user_id: user.id).sum(:total_cents)

It works.

Until it doesn’t.

The failure: your domain boundaries are now dictated by who imported whose model first, not by intentional APIs.

In production, this tends to break in three ways:

  1. Schema coupling

    • one engine “knows” another engine’s columns and associations
  2. Performance coupling

    • one engine triggers queries the owning team never profiled
  3. Change management coupling

    • refactors in Billing break Catalog without an explicit contract

Naive approach #2: Share tables across domains “because it’s easy”

Shared tables become a subtle form of microservices… without service boundaries. Everyone writes to them. No one owns them. You get inconsistent invariants and impossible debugging.


The correct approach: engines + explicit public APIs

A modular monolith is not “many apps”. It’s one app with internal contracts.

You want:

  • Engines own their models and tables (or at least own the write paths)
  • Other engines interact via public interfaces
  • Cross-engine reads are allowed only through well-defined query objects
  • Cross-engine writes go through commands/services that enforce invariants

A concrete structure that survives production

1) Use isolate_namespace and keep constants private by default

In your engine:

# engines/billing/lib/billing/engine.rb
module Billing
  class Engine < ::Rails::Engine
    isolate_namespace Billing
  end
end

This won’t stop Ruby from referencing constants if you expose them — but it forces you to be intentional about namespacing and routes.

2) Create a “public surface” folder and treat everything else as private

A practical pattern:

  • engines/billing/app/public/billing/... (or app/api, app/contracts)
  • only code in this folder is allowed to be referenced externally

Example public interface:

# engines/billing/app/public/billing/invoices.rb
module Billing
  module Invoices
    def self.total_cents_for(user_id:)
      Billing::Invoice.where(user_id: user_id).sum(:total_cents)
    end
  end
end

Now other engines do:

total = Billing::Invoices.total_cents_for(user_id: user.id)

Not:

Billing::Invoice.where(...)

This seems “small”, but it’s the difference between contracted coupling and accidental coupling.


Verified SQL + ActiveRecord: where boundaries leak into performance

A common boundary leak is “I just need one number from Billing”.

The leaky version (couples + can get expensive)

# called inside a loop elsewhere
Billing::Invoice.where(user_id: user.id).sum(:total_cents)

Generated SQL (verified):

SELECT SUM("billing_invoices"."total_cents")
FROM "billing_invoices"
WHERE "billing_invoices"."user_id" = $1

The SQL itself is fine — the bug is how it’s used.

If this runs per-user in a list, you’ve built an N+1 across engines.

The correct version: move it behind a query that supports batching

# engines/billing/app/public/billing/invoices.rb
module Billing
  module Invoices
    def self.totals_cents_for_user_ids(user_ids:)
      Billing::Invoice
        .where(user_id: user_ids)
        .group(:user_id)
        .sum(:total_cents)
    end
  end
end

Generated SQL (verified):

SELECT "billing_invoices"."user_id", SUM("billing_invoices"."total_cents")
FROM "billing_invoices"
WHERE "billing_invoices"."user_id" IN ($1, $2, $3)
GROUP BY "billing_invoices"."user_id"

Now callers can do one call per page, not per row.

This is the key mental shift:

Engine boundaries are also query-shape boundaries.

If you don’t design for batching, engines will create performance regressions.


Data and migrations: choose one of two sane strategies

Strategy A: Centralized migrations, clear table ownership (common and stable)

  • Keep migrations in the main app
  • Engines ship code, not schema
  • You still document: “Billing owns billing_* tables”

This avoids painful migration install/uninstall patterns.

Strategy B: Engine-owned migrations (only if you truly need it)

If you ship engines as reusable packages, engine migrations make sense.

But in a single repo modular monolith, engine migrations often add friction:

  • rails billing:install:migrations workflows
  • duplicated migration copies
  • ordering issues

Pick one strategy and stick to it.


Production pitfalls you will hit

1) Zeitwerk constant collisions

Two engines define the same constant names in different places and autoloading becomes “who loaded first”.

Fix: strict namespacing + avoid top-level constants.

2) “Shared” concerns becoming global glue

A shared concerns folder becomes the new dumping ground. Now every engine depends on it.

Fix: keep shared code minimal and boring:

  • instrumentation
  • small utilities
  • cross-cutting framework adapters

Not business logic.

3) Cross-engine associations are a trap

Associations encourage direct model access.

If you must associate across engines, prefer:

  • IDs + query objects
  • explicit read models
  • or service boundaries

4) Test suites lying to you

If tests boot everything, boundary violations don’t fail fast.

Add checks:

  • a dependency graph linter (even a simple script)
  • a convention like “public surface only”
  • CI validation that engines don’t require private paths

A practical “engine boundary contract”

A simple, enforceable rule:

  • External code may call only Billing::Public::* (or Billing::API::*)
  • It may not reference Billing::Invoice, Billing::Refund, etc.

Even without fancy tooling, this convention changes how teams design interfaces.


Rule of thumb

If another engine needs your model, you don’t have a boundary — you have shared ownership.

Use engines to create contracts. Contracts create stability. Stability is what makes big Rails apps scale without becoming microservices-by-accident.

Was this article helpful?

Your feedback helps us improve our content

Be the first to vote!

How We Verify Conversions

Every conversion shown on this site follows a strict verification process to ensure correctness:

  • Compare results on same dataset — We run both SQL and ActiveRecord against identical test data and verify results match
  • Check generated SQL with to_sql — We inspect the actual SQL Rails generates to catch semantic differences (INNER vs LEFT JOIN, WHERE vs ON, etc.)
  • Add regression tests for tricky cases — Edge cases like NOT EXISTS, anti-joins, and predicate placement are tested with multiple scenarios
  • Tested on Rails 8.1.1 — All conversions verified on current Rails version to ensure compatibility

Last updated: January 16, 2026

Try These Queries in Our Converter

See the SQL examples from this article converted to ActiveRecord—and compare the SQL Rails actually generates.

106
R

Raza Hussain

Full-stack developer specializing in Ruby on Rails, React, and modern JavaScript. 15+ years upgrading and maintaining production Rails apps. Led Rails 4/5 → 7 upgrades with 40% performance gains, migrated apps from Heroku to Render cutting costs by 35%, and built systems for StatusGator, CryptoZombies, and others. Available for Rails upgrades, performance work, and cloud migrations.

💼 15 years experience 📝 12 posts

Responses (0)

No responses yet

Be the first to share your thoughts