The Modular Monolith in Rails: Engines, Packwerk and Boundaries

davidslv1 pts0 comments

The Modular Monolith in Rails: Engines, Packwerk & Boundaries | Davidslv

Skip to content

By David Silva.<br>Last updated: June 2026 (Rails 8, Zeitwerk-era).

What follows is a question I get asked, in one form or another, almost every month: “We have a big Rails app, it’s getting hard to work in, everyone’s stepping on each other — do we need to break it into microservices?”

Almost always, the answer is no. What you need is a modular monolith : a single deployable Ruby on Rails application whose internals are split into well-defined modules with enforced boundaries. You keep one deploy, one test suite, one database connection pool and one place to reason about a request — but you stop letting any object reach into any other across the whole codebase.

This page is the honest, current (Rails 8, Zeitwerk-era) guide to doing that. It covers what a modular monolith actually is, the three real mechanisms for building one in Rails — Rails Engines , Packwerk , and plain namespaced modules — and a fair, side-by-side comparison so you can pick. It walks through namespace isolation, inter-module boundaries, data ownership, and testing. And — because nobody else seems to write this part down — it tells you when you should not do any of this.

I lead the decomposition of a large production Rails monolith into modular engines, and I’ve written a whole book about it. Where a topic deserves a chapter rather than a section, I’ll link to the relevant free chapter so you can go deeper.

What is a modular monolith in Rails?

A monolith is a single application you build, test and deploy as one unit. A modular monolith is the same thing with one extra rule: the code inside is divided into modules that own their data and behaviour, and modules talk to each other only through public interfaces — never by reaching into each other’s internals.

The contrast that matters is not monolith-versus-microservices. It’s modular-versus-big ball of mud. A big ball of mud is also a monolith; it just has no internal boundaries, so any controller can touch any model, any model can call any service, and a change to billing quietly breaks onboarding because they share a half-dozen models nobody owns.

A modular monolith gives you most of what people actually want from microservices — clear ownership, independent reasoning, the ability for two teams to work without colliding — without the distributed-systems tax: no network between your modules, no eventual consistency you didn’t ask for, no per-service deploy pipeline, no cross-service transaction you now have to fake with a saga. You get a function call where a microservice would give you an HTTP round trip that can fail.

When you should use one

Reach for a modular monolith when:

The codebase has grown past the point where one person holds it all in their head, or more than one team commits to it daily.

You can already name the domains — billing, onboarding, notifications, reporting — even if the code doesn’t reflect them yet.

Changes in one area keep breaking another, and you can’t tell why without reading half the app.

You want the option of extracting a service later, but you don’t want to pay for distribution today.

That last point is the strategic one. A clean module boundary is the cheapest possible insurance policy: if a module genuinely needs to become its own service in two years, a well-bounded module is a far easier extraction than a tangle. You’re buying optionality, not microservices.

The three mechanisms in Rails

There are exactly three serious ways to enforce modules inside a Rails app. They are not mutually exclusive, but most teams should start with one.

1. Rails Engines

An engine is a miniature Rails application that lives inside your app. It’s the mechanism Rails itself is built from — Rails::Application is an Engine, which is why the pattern composes so naturally. Each engine has its own app/ directory, its own routes, its own migrations, and its own namespace.

A mountable engine has a small, predictable layout. The single-nested namespace directory is the part that matters most:

components/<br>billing/<br>lib/<br>billing/<br>engine.rb<br>app/<br>models/<br>billing/<br>ledger.rb # defines Billing::Ledger<br>config/<br>routes.rb<br>db/<br>migrate/

The engine declaration is four lines:

# components/billing/lib/billing/engine.rb<br>module Billing<br>class Engine ::Rails::Engine<br>isolate_namespace Billing<br>end<br>end

That single isolate_namespace Billing line is the whole point. It tells Rails that everything in this engine lives under the Billing namespace: models, controllers, routes, table-name prefixes, helpers. The engine becomes a unit you can reason about in isolation. I pull apart how this works from the Rails source — why your app is itself an engine, and what isolate_namespace actually does to the inheritance chain — in Rails Engines from the Inside Out.

You mount an engine in the host app’s routes:

# config/routes.rb<br>Rails.application.routes.draw do<br>mount Billing::Engine, at: "/billing"<br>end

A...

rails billing engine monolith modular engines

Related Articles