DEV Community

Alex Aslam
Alex Aslam

Posted on

Modular Monoliths in Rails: Taming Complexity Without Microservices Madness

"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 about Order knows about Payment).
  • 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
Enter fullscreen mode Exit fullscreen mode

You organize by domain:

app/
  domains/
    accounting/
      models/
        payment.rb
      controllers/
      services/
    inventory/
      models/
        order.rb
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Update autoload paths in config/application.rb:

config.autoload_paths += Dir["#{config.root}/app/domains/*"]
Enter fullscreen mode Exit fullscreen mode

2. Enforce Boundaries with Dependency Rules

Bad:

class Order < ApplicationRecord
  def process_payment
    Payment.create!(...) # Direct cross-domain call
  end
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Try it: Extract one domain (e.g., Billing) in a branch.
  2. Measure: Did tests speed up? Is coupling reduced?
  3. 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.