Stop Microservices Overkill: Build a Modular Monolith Instead

In a growing application, the "Big Ball of Mud" is a constant threat. As a Developer, I’ve found that the Modular Monolith—combined with the Interactor Pattern using Ruby On Rails—is the most effective way to maintain clean boundaries without the overhead of microservices.
By isolating business logic into dedicated modules and using Interactors, we ensure the system is predictable, testable, and empathetic to other developers.
1. The Architectural Blueprint
A Modular Monolith divides the application into logical business domains (e.g., Inventory, Billing, Sales). Each domain owns its logic and its data tables.
2. Setting Up the Environment
Add the necessary gems to your Gemfile:
gem 'interactor' group :development, :test do gem 'rspec-rails' gem 'factory_bot_rails' end
Run bundle install and rails generate rspec:install.
3. Designing the Modular Structure
We will use namespacing to isolate our modules within the app/modules directory.
app/ ├── modules/ │ └── inventory/ │ ├── models/ # Database tables: inventory_products │ └── interactors/ # Business logic for inventory spec/ ├── modules/ │ └── inventory/ # Isolated unit tests
Update config/application.rb to allow Rails to autoload these paths:
config.autoload_paths += Dir[Rails.root.join('app', 'modules', '**/')]
4. Implementation: The Interactor
Let's build a scenario: Reducing stock when a purchase occurs. The Interactor handles a single business rule.
# app/modules/inventory/interactors/inventory/reduce_stock.rb
module Inventory
class ReduceStock
include Interactor
def call
product = context.product
quantity = context.quantity
if product.stock >= quantity
product.update!(stock: product.stock - quantity)
else
# Fail fast if business logic is violated
context.fail!(message: "Insufficient stock for #{product.name}")
end
rescue StandardError => e
context.fail!(message: "System Error: #{e.message}")
end
end
end
5. Reliability: Unit Testing with RSpec
Unit tests for Interactors are fast and focused because they test logic, not the entire framework.
# spec/modules/inventory/interactors/reduce_stock_spec.rb
require 'rails_helper'
RSpec.describe Inventory::ReduceStock, type: :interactor do
let(:product) { create(:product, stock: 10) }
describe '.call' do
context 'when stock is available' do
let(:context) { described_class.call(product: product, quantity: 4) }
it 'succeeds and reduces the stock' do
expect(context).to be_a_success
expect(product.reload.stock).to eq(6)
end
end
context 'when stock is insufficient' do
let(:context) { described_class.call(product: product, quantity: 15) }
it 'fails with an error message' do
expect(context).to be_a_failure
expect(context.message).to include("Insufficient stock")
end
it 'does not change the database state' do
context
expect(product.reload.stock).to eq(10)
end
end
end
end
6. Coordinating Workflows with Organizers
Organizers allow different modules to collaborate without becoming tightly coupled.
# app/modules/sales/interactors/sales/process_checkout.rb
module Sales
class ProcessCheckout
include Interactor::Organizer
# Cross-module workflow
organize Inventory::ReduceStock, Billing::CreateInvoice
end
end
7. Senior Takeaways for Scale
- Database Isolation: Use table prefixes (e.g.,
inventory_products) to clearly define data ownership. - Technical Empathy: Use Organizers as a "Table of Contents" for your business logic. A PM or new developer should be able to read the
organizelist and understand the business flow instantly. - Encapsulation: Never allow the
Inventorymodule to queryBillingtables directly. Always communicate through the Interactor's Context. - Fail Fast: Always check your requirements at the start of the
callmethod and fail early to prevent partial data states.
By following this pattern, you are building a system that is not only robust but also ready to be transitioned into microservices in the future—if ever necessary—with minimal friction.
Github: source code