In the evolution of Ruby on Rails monoliths with embedded React views, complexity often grows in hidden layers. Models leak across layers, JavaScript bundles bloat, and teams struggle with test sprawl and unclear boundaries. But there’s a middle ground between monolith chaos and full microservices.
By modularizing Rails backends into Engines and separating React logic into distinct, domain-scoped bundles, teams can gain clarity, testability, and long-term scalability; all while keeping the development speed of a monolith.
This post came out of a conversation with a colleague, where we were deep in a Rails + React codebase and started talking about how things were getting a bit… entangled. Business logic was starting to spread out across unrelated parts of the app. Some views loaded React components dynamically while others were embedded directly in ERB. Over time, it became clear that separating responsibilities wasn’t just a nice-to-have; it was necessary for sanity.
That conversation sparked an idea for this post, something that I have done in the past but never wrote about. What if we could use Rails Engines to carve out logical boundaries inside the backend, and use that same mindset on the frontend with React? Not with a full-blown microservices setup, but something that sits comfortably in the “modular monolith” sweet spot.
This guide walks through:
- Rails Engines: carving out backend domains with their own APIs, tests, and logic
- Frontend separation: React modules that align with backend engines, improving structure and load times
- The interplay between these systems, and how to integrate without over-engineering
NOTE: The code in this article is pseudocode that has not being tested. Kept it for demonstration purposes only.
The Case for Modularizing Monoliths
Before diving into implementation, let’s talk about the problem.
Monolithic Rails apps typically start clean. One User
, one Product
, one Order
. A few React components layered on top. But then you add Stripe billing, internal admin dashboards, A/B testing, marketing automation, complex search, PDF generation… and now your once-tidy app feels like a tangled ball of yarn.
Common issues:
- All business logic is accessible everywhere (hard to know what depends on what)
- Specs are slow and global; hard to isolate regressions
- React components grow into a single Webpack bundle or JS file
- Frontend code has no clear ownership or scope
This makes the app hard to test, hard to onboard into, and increasingly hard to scale.
Rails Engines: Modular Backend Architecture
What is a Rails Engine?
A Rails Engine is like a mini-Rails app that lives inside your main Rails app. It can have its own: Models, Controllers, Routes, Migrations, Views, Tests, you name it! Each Engine encapsulates a slice of your application, with clearly defined inputs and outputs.
Think of it as a package or gem; but one that still runs inside your app, shares your database connection (maybe, depends on setup), and plays well with the Rails ecosystem. Some gems are kind of doing these already and you might recognize them as such.
Why Engines?
Engines give you:
- Isolation: You can control what code is public or private within the engine
- Testing: Engines can have their own test suite
- Ownership: Teams can “own” an engine, keeping boundaries clear
- Refactorability: If you ever do want to extract to a service, it’s way easier
How to Start Modularizing?
Here’s a rough process:
- Identify Domains
Look for functional domains:Billing
,Admin
,Catalog
,Support
,Notifications
. Each of these is a candidate for its own engine. - Begin Isolating Code
- Move models and services into their own namespace (
Billing::Invoice
,Billing::StripeClient
) - Keep APIs narrow: prefer service objects over raw ActiveRecord access
- Ensure tests don’t leak implementation details from other domains
- Move models and services into their own namespace (
- Generate a Mountable Engine
rails plugin new engines/billing --mountable
This gives you a full mini-Rails app under engines/billing
.
- Migrate Logic
Move controllers, models, views, serializers, services, and jobs from the main app into the engine. Define a public API. - Wire It Up
Mount it inroutes.rb
(you could also make your engine mount automagically, but will avoid such discussion now):
mount Billing::Engine, at: "/billing"
- Test It
Add test setup inside the engine. Run independently withbin/rails test
or useRSpec
.
Patterns That Help
- Keep APIs tight: Expose service objects or serializers, not raw models.
- Avoid circular dependencies: Engines should never rely on the host app to function. Inject what they need.
- Inject dependencies via initializers: If an engine needs external behavior (e.g., a currency formatter), register it at app boot.
Structuring the Frontend: React as Modular Engines
Pushing Modularity Further: Independent Engine Repos
If each Rails Engine lives in its own repository; say, rails-billing-engine
, rails-admin-engine
, then the React components that power those engines should live alongside them. That means every engine becomes a self-contained app, with:
- Backend logic (models, controllers, routes)
- A mountable Rails engine
- A frontend React module
- Its own build system
- Its own versioning and deploy pipeline (if needed)
This mirrors microservice boundaries without abandoning the monolith runtime.
The Layout
Take this example directory structure across multiple repositories:
/rails-billing-engine
/app
/controllers
/models
/views
/frontend
/components
/index.js
/package.json
/webpack.config.js
billing_engine.gemspec
/rails-admin-engine
/app
/frontend
/package.json
admin_engine.gemspec
/main-rails-app
/Gemfile
/engines/
billing/ # Git submodule or vendored copy of rails-billing-engine
admin/ # Ditto
/app/views/
/public/packs/
Each engine builds and ships its own frontend bundle. The main app doesn’t build it, it just ingests it.
How Bundling Works
In each engine repository:
- You maintain a
frontend/
directory with its own React codebase. - You use Webpack (or Vite) to build a self-contained JS bundle, e.g.,
billing.js
. - You output that bundle to a known location: e.g.,
/dist/billing.js
or into theengine
folder.
In rails-billing-engine/webpack.config.js
:
module.exports = {
entry: './frontend/index.js',
output: {
filename: 'billing.js',
path: path.resolve(__dirname, 'app/assets/javascripts')
}
};
Then, when your main app includes this engine via a gem or git submodule, it can load the compiled billing.js
directly:
<%= javascript_include_tag 'billing' %>
Optionally, you can also publish these bundles to a private NPM registry and install them via the main app’s frontend build.
Mounting in the Main App
In your main Rails app:
# config/routes.rb
mount Billing::Engine, at: "/billing"
mount Admin::Engine, at: "/admin"
Each engine brings its backend and its precompiled frontend.
Your layout or view template handles JS ingestion per route:
<%= yield %>
<%= javascript_include_tag 'billing' if request.path.start_with?("/billing") %>
<%= javascript_include_tag 'admin' if request.path.start_with?("/admin") %>
Or, each engine’s layout can handle loading its JS:
<!-- engines/billing/app/views/layouts/billing/application.html.erb -->
<%= javascript_include_tag 'billing' %>
<%= yield %>
Benefits of Per-Repo Isolation
Decoupling a Rails + React monolith doesn’t have to mean going full microservices. With Rails Engines and per-engine React frontends, each in their own repositories, you can preserve the simplicity of a monolith while gaining the modularity of a distributed system.
Each engine becomes a complete domain unit: its own backend logic, its own React UI, and its own test and build pipelines. The main app simply mounts the engine and ingests its precompiled assets. This setup creates clear ownership boundaries, accelerates development across teams, and makes it easier to scale or extract services later; without a huge architectural leap up front.
So whether you’re wrestling with a growing monolith or planning a large-scale rewrite, consider this hybrid approach. It gives you:
- Domain-aligned ownership
- Isolated build and test flows
- Reusable patterns for future scale
- Simplicity of integration with flexibility of evolution
This is modular monolith architecture at its best: clean, efficient, and built for growth.
References
- Kelly Sutton — How to Break Apart a Rails Monolith
- Mat Moore — How to Break Up a Rails Monolith on DEV
- bhserna — Should You Split Your Rails App Into Backend and Frontend?