The case for modular monoliths
A Rails application starts small. Six months in, you're shipping features faster than anyone expected. Two years later, your codebase has grown to 50,000 lines spread across a dozen domains—payment processing, user management, inventory, reporting, notifications—all tangled together in app/models and app/controllers. You're facing a choice: break into microservices or find a better way to organize the monolith itself. Modular monoliths in Rails: patterns, pitfalls and fixes offers a third path: keep the deployment simplicity of a monolith while building the organizational clarity of separate services.
We've watched this pattern succeed across e-commerce platforms, SaaS dashboards, and ride-hailing backends. The key is treating your monolith like it's already multiple services, without the operational overhead. This approach lets teams scale code organization without scaling operational complexity—no service discovery, no network calls between internal domains, no deployment coordination headaches.
Group code by domain
The first step is abandoning the default Rails structure where all models live together, all controllers together, all jobs together. Instead, organize by business domain. Each domain gets its own directory tree with models, controllers, jobs, and migrations that live only in that domain's context.
Consider an e-commerce platform with three main domains: Orders, Payments, and Fulfillment. Instead of:
app/
models/
order.rb
payment.rb
shipment.rb
controllers/
orders_controller.rb
payments_controller.rb
shipments_controller.rb
Organize as:
app/
domains/
orders/
models/
order.rb
controllers/
orders_controller.rb
services/
create_order.rb
payments/
models/
payment.rb
controllers/
payments_controller.rb
jobs/
process_payment_job.rb
fulfillment/
models/
shipment.rb
shipment_item.rb
controllers/
shipments_controller.rb
services/
ship_order.rb
Each domain encapsulates related logic. Routes remain simple—Rails still knows where to find controllers. The benefit is cognitive: when shipping a fulfillment feature, developers navigate one domain folder instead of hunting through 20,000 lines across app/models and app/controllers.
Ensure Rails loads these directories automatically by updating config/application.rb:
config.autoload_paths += Dir.glob("#{config.root}/app/domains/*/")
Namespace your constants to avoid collisions. A Payment model lives in Payments::Payment, not just Payment. This keeps domains mentally separate even when loaded into the same process.
Enforce boundaries with Packwerk
Organizing code into folders is easy. Enforcing that Orders doesn't secretly depend on Fulfillment internals is hard. That's where Packwerk comes in—it's a gem built by Shopify to enforce package boundaries in Rails monoliths.
Packwerk treats each domain as a package. You declare what each package is allowed to depend on, then run a check to catch violations. A package/package.yml file in each domain specifies its contract:
app/domains/orders/package.yml
enforces_privacy: true
dependencies:
- "app/domains/common"
- "." # itself
This says the Orders package is private (no one else can touch its internals) and only depends on the Common package and itself. Now if Fulfillment tries to import Orders::Order directly from the model layer, Packwerk catches it:
# app/domains/fulfillment/services/ship_order.rb
class Fulfillment::ShipOrder
def call(order)
# VIOLATION: accessing Orders internal model
Orders::Order.find(order.id).update(status: 'shipped')
end
end
Run packwerk check and it flags the violation. The fix is to go through Orders' public API instead of its private model.
Setting up Packwerk:
- Add the gem: bundle add packwerk
- Initialize: bundle exec packwerk init
- Create package.yml in each domain
- Run bundle exec packwerk update-todo to establish current state
- Add packwerk check to CI
Packwerk isn't perfect—it checks file requires, not method calls—but it catches the obvious violations and makes teams think about coupling.
Public APIs between packages
Once boundaries are enforced, you need a clear way for Fulfillment to ask Orders for data. This is the public API: a single entry point per domain that exposes only what's safe for outsiders to use.
Create a public interface file for each package. For Orders, create app/domains/orders/public_api.rb:
# app/domains/orders/public_api.rb
module Orders
class PublicAPI
def self.find_order(order_id)
# Look up the order and return a read-only DTO
order = Order.find(order_id)
OrderDTO.new(order)
end
def self.mark_shipped(order_id)
# Transition the order to shipped state
order = Order.find(order_id)
order.transition_to(:shipped)
order.save!
end
end
# Data transfer object—safe to pass between domains
class OrderDTO
def initialize(order)
@order = order
end
def id
@order.id
end
def status
@order.status
end
def total
@order.total
end
# No methods that modify state
end
end
Now Fulfillment uses only the public API:
# app/domains/fulfillment/services/ship_order.rb
class Fulfillment::ShipOrder
def call(order_id)
order = Orders::PublicAPI.find_order(order_id)
# Create shipment records
shipment = Shipment.create!(order_id: order.id)
# Tell Orders to mark it shipped
Orders::PublicAPI.mark_shipped(order_id)
# Trigger fulfillment workflow
FulfillmentJob.perform_later(shipment.id)
end
end
This pattern gives you the stability of internal APIs. Fulfillment doesn't care that Orders refactored its model layer—the PublicAPI contract is stable. When it's time to extract Orders into a microservice, you already have a clear boundary to turn into HTTP endpoints.
Extract services only when needed
A common mistake is extracting microservices too early, before you've clarified domain boundaries. By building a modular monolith first, you prove the boundaries work before paying the cost of network calls and distributed debugging.
Extract only when:
- The domain has truly independent scaling needs (Payments might spike during sales events)
- The team is large enough that a separate service reduces coordination overhead
- The domain needs a different tech stack or database
- You've run Packwerk for months and the package boundary is rock-solid
If you've built a clean public API, extraction is mechanical: create a new Rails app for Payments, move app/domains/payments/ there, replace the PublicAPI calls with HTTP clients. The business logic stays the same; you've just moved the deployment boundary.
Keep fast domains in the monolith. Most SaaS platforms we work with extract one or two domains (maybe Payments and Analytics) while keeping 10+ domains in the monolith. The operations overhead doesn't justify extracting everything.
Common mistakes
Skipping the public API: Developers create domains, organize code, but still reach directly into other domains' models. Packwerk catches some violations, but the real fix is discipline around PublicAPI. Code review: if you see Orders::Order used outside Orders/, block it.
Shared models across domains: A User model that every domain needs tempts you to put it at the root of app/models. Resist this. Create a Common or Shared domain, make User live there, and have each domain go through Common::PublicAPI to access user data. This keeps the dependency graph clear.
Circular dependencies: Orders depends on Payments, Payments depends on Orders. Packwerk catches this, but the design is broken. Break cycles by introducing a third domain or using async messaging. Orders publishes an "OrderShipped" event, Payments listens via a job. No circular dependency.
Ignoring database coupling: A modular monolith shares a database. This is fine—it's your safety net. But don't let this excuse tight coupling. Use foreign keys only within a domain. Between domains, use IDs and the public API. When you eventually extract to a microservice, you won't have the database to hide loose coupling.
Key takeaways
- Organize code by business domain, not by technical layer (models, controllers). Use folders like app/domains/orders/, app/domains/payments/.
- Use Packwerk to enforce package boundaries and catch accidental cross-domain dependencies in CI.
- Build a PublicAPI class in each domain that exposes read-only data objects and safe operations. Force all cross-domain communication through these APIs.
- Keep the monolith modular first. Extract to microservices only after the boundaries are proven and the cost is justified.
- Treat your monolith as if it will become microservices one day. Use IDs and async messaging instead of direct object references between domains.






Comments
Loading comments…