arrow_backBACK_TO_LOGS
May 7, 20265 MIN READvisibility 13

Stop Microservices Overkill: Build a Modular Monolith Instead

#RUBY ON RAILS #METAPROGRAMMING #BACKEND

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 organize list and understand the business flow instantly.
  • Encapsulation: Never allow the Inventory module to query Billing tables directly. Always communicate through the Interactor's Context.
  • Fail Fast: Always check your requirements at the start of the call method 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

Gilang Ramadan

Written by Gilang Ramadan

Fullstack Engineer specialized in high-performance backend systems and modern frontend architectures.