"Why does our Rails app feel like a Jenga tower?"
If you’ve ever:
✅ Scrolled through a 2,000-line User
model...
✅ Hesitated to change code because "everything breaks"...
✅ Debated microservices just to escape spaghetti logic...
—Then modular monoliths might be your answer.
Let’s explore how to structure Rails apps without rewriting everything—using patterns from giants like Shopify and GitHub.
The Modular Monolith Mindset
1. What’s Wrong with "Rails Way" Folders?
Traditional app/models
, app/controllers
work for small apps. But as complexity grows:
- Models become dumping grounds (User + Order + Payment + Notification logic).
-
Circular dependencies creep in (
User
knows aboutOrder
knows aboutPayment
). - Testing slows down because loading the entire app is required.
2. Enter "Component-Based Rails"
Instead of:
app/
models/
user.rb
order.rb
payment.rb
You organize by domain:
app/
domains/
accounting/
models/
payment.rb
controllers/
services/
inventory/
models/
order.rb
Key Benefits:
- ✅ Explicit boundaries: No more accidental coupling.
- ✅ Faster tests: Load only the components you need.
- ✅ Easier extraction: Components can become services later.
Step-by-Step: Modularizing a Rails App
1. Start with Logical Separation
Use Rails engines or plain Ruby modules:
# app/domains/accounting/payment.rb
module Accounting
class Payment < ApplicationRecord
# Only payment-related logic here
end
end
Update autoload paths in config/application.rb
:
config.autoload_paths += Dir["#{config.root}/app/domains/*"]
2. Enforce Boundaries with Dependency Rules
Bad:
class Order < ApplicationRecord
def process_payment
Payment.create!(...) # Direct cross-domain call
end
end
Good: Use pub/sub or service objects:
# app/domains/orders/order.rb
module Orders
class Order < ApplicationRecord
def process_payment
Accounting::PaymentProcessor.call(self)
end
end
end
3. Isolate Dependencies
- Database: Schema per component (use PostgreSQL schemas or prefixes).
-
Gems: Avoid global gems; load per-component (e.g.,
require: false
in Gemfile).
4. Test in Isolation
# spec/domains/accounting/payment_spec.rb
require_relative '../../../app/domains/accounting/payment'
RSpec.describe Accounting::Payment do
# No need to boot the entire Rails app
end
Real-World Tradeoffs
When Modular Monoliths Shine
🔹 Team scaling: Different squads own different domains.
🔹 Complex business logic: Fintech, e-commerce, SaaS.
When to Avoid Them
🔸 Tiny apps: Overkill for < 10 models.
🔸 If you need polyglot services: Go microservices instead.
Lessons from the Trenches
-
Shopify: Uses "components" with
app/{domain}
structure. - GitHub: Modularized despite being a monolith.
"A monolith is fine until it’s not. Modular monoliths buy you time."
— Senior Rails Dev at Scale-Up
Your Next Steps
-
Try it: Extract one domain (e.g.,
Billing
) in a branch. - Measure: Did tests speed up? Is coupling reduced?
- Iterate: Gradually componentize.
Tools to Explore:
-
packwerk
(Shopify’s boundary enforcer) -
rails plugin new
(for engine-based isolation)
🔥 Hot Take: Microservices aren’t the only escape from spaghetti code. A well-structured monolith can scale further than you think.
What’s your experience?
- Have you tried modular Rails?
- Did it work—or backfire?
Let’s discuss in the comments! 👇
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.