- Home
- Blog
- Ruby & Rails Core
- Modular Monoliths in Rails 8.1 with Engines: Boundaries Without Microservices
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.
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::Invoicecolumn and a data migration. - Team B deploys a change in a different area that loads invoices indirectly.
- In production, requests start failing with
PG::UndefinedColumn— but 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/billingengines/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:
-
Schema coupling
- one engine “knows” another engine’s columns and associations
-
Performance coupling
- one engine triggers queries the owning team never profiled
-
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/...(orapp/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:migrationsworkflows - 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::*(orBilling::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
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.
Deep Dive into ActiveRecord
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.
More on Ruby & Rails Core
LEFT JOIN + WHERE in ActiveRecord: The Trap That Turns It Into INNER JOIN
LEFT JOIN queries often break the moment you add a WHERE on the joined table—silently turning into INNER JOIN behavior. Learn the correct Rails patterns (ON vs WHERE, scoped associations, where.missing/where.associated, EXISTS) and how to verify generated SQL.
Ruby 4.0.0 Is Out: What Rails & ActiveRecord Devs Actually Need to Know
Ruby 4.0.0 is released (Dec 25, 2025). For Rails apps, the real risks are stdlib gem changes (CGI), Net::HTTP behavior shifts, and Bundler 4. Here’s what breaks first, how to fix safely, and what Ruby 4 performance does—and doesn’t—solve.
N+1 Isn’t a Rails Problem: It’s a Query-Shaping Problem
Copied SQL into Rails and still got N+1? Rewrote in ActiveRecord and still got N+1? The fix is a set-based mental model: where N+1 really comes from, how to verify it, and the AR patterns that eliminate it safely.
Responses (0)
No responses yet
Be the first to share your thoughts