The modular monolith isn’t about making your monolith less monolithic; it’s about making it more intentionally structured so you can get the benefits of microservices (like independent deployability and team autonomy) without the operational overhead.

Imagine a traditional monolith. It’s a big ball of mud. If you want to change the "Order Processing" module, you might accidentally break "Inventory Management" because they’re tightly coupled. With a modular monolith, we’re going to break that ball of mud into well-defined, independently deployable modules, but keep them within a single deployable unit.

Let’s say we have a web application built with Ruby on Rails. Our modules will be Customers, Products, and Orders.

# config/routes.rb
Rails.application.routes.draw do
  mount Customers::Engine => "/customers"
  mount Products::Engine => "/products"
  mount Orders::Engine => "/orders"
  root to: "home#index"
end

# Gemfile
# ... other gems
gem 'customers', path: '../modules/customers'
gem 'products', path: '../modules/products'
gem 'orders', path: '../modules/orders'

# In each module's gemspec (e.g., modules/customers/customers.gemspec)
Gem::Specification.new do |s|
  s.name        = 'customers'
  s.version     = '0.1.0'
  s.summary     = 'Customer management module'
  s.files       = Dir['lib/**/*.rb', 'config/**/*.rb', 'app/**/*.rb'] # Include all necessary files
  s.require_paths = ['lib', 'app']
  # ... other settings
end

# In each module's lib/customers/engine.rb
module Customers
  class Engine < ::Rails::Engine
    isolate_namespace Customers
  end
end

# In each module's app/controllers/customers/application_controller.rb
module Customers
  class ApplicationController < ::ApplicationController
    # Module-specific controller logic
  end
end

The core idea is to treat each module as if it were a microservice, but keep its code within the same repository and deploy it as a single unit. Each module has its own models, controllers, views, and even its own database tables (though they might share a single database instance for simplicity in a modular monolith). The crucial part is enforcing strict boundaries.

Here’s a simplified view of how the Orders module might interact with the Customers module. The Orders module cannot directly call Customer.find(id) from the Customers module’s models. Instead, it must go through a defined interface, often called a "service object" or "port."

# app/services/orders/customer_lookup_service.rb
module Orders
  class CustomerLookupService
    # This service object is the 'port' for the Orders module to access customer data.
    # It acts as an adapter, calling the actual Customer model from the Customers module.
    def find_customer(customer_id)
      # This is where the boundary is enforced. Orders doesn't directly touch Customers::Customer model.
      # It calls through a defined interface.
      customer = Customers::Customer.find_by(id: customer_id)
      if customer
        { id: customer.id, name: customer.name, email: customer.email }
      else
        nil
      end
    end
  end
end

# In app/controllers/orders/orders_controller.rb (within the Orders module)
module Orders
  class OrdersController < ApplicationController
    def show
      customer_info = Orders::CustomerLookupService.new.find_customer(params[:customer_id])
      # Use customer_info to display order details...
      if customer_info
        @order = Order.find(params[:id])
        @customer_name = customer_info[:name]
        # ... render order details
      else
        flash[:alert] = "Customer not found."
        redirect_to root_path
      end
    end
  end
end

# In app/models/customers/customer.rb (within the Customers module)
module Customers
  class Customer < ApplicationRecord
    # ... customer attributes and validations
  end
end

The magic happens in how you structure your dependencies. You’d typically use a gem system (like in the Gemfile example above) where each module is a separate gem. The main application gem depends on these module gems. However, you configure these gems to be loaded into the same Rails process. The isolate_namespace in the Engine class is key to preventing naming collisions between modules.

The real power comes from enforcing strict dependency rules. A module should only depend on modules "below" it in a predefined hierarchy, or on core infrastructure. For example, Orders might depend on Customers and Products, but Customers should never depend on Orders. This prevents circular dependencies and keeps modules loosely coupled. Tools like dependency_graph for Ruby can help visualize these relationships.

The one thing that most people miss is that the "boundary" isn’t just about code organization; it’s about enforced communication patterns. The module that needs data from another module doesn’t reach into the other module’s database or models directly. Instead, it calls a well-defined public API, often implemented as a service object or a dedicated adapter within the consuming module. This forces the consuming module to explicitly declare its dependency on the interface of the other module, rather than its internal implementation details.

This approach allows you to independently develop, test, and even potentially extract modules into true microservices later on if the need arises, without the immediate complexity of managing distributed systems.

The next challenge you’ll face is managing shared data and transactions across these module boundaries.

Want structured learning?

Take the full Monolith course →