This is an adapted excerpt from Chapter 17 of Modular Rails: Architecture for the Long Game, my book on building maintainable Ruby on Rails applications using Rails Engines.


“Majestic monolith. The vast majority of web applications should start here and never leave.” – David Heinemeier Hansson

The microservices conversation has been going on for over a decade now, and the industry is starting to reach a consensus that most teams arrived at too late: distributed systems are expensive, and the default starting point should be a well-structured monolith.

This chapter makes the case that a modular monolith – specifically, a Rails application structured with engines – is the right default for most teams. Not because microservices are bad, but because the operational cost of distribution is almost always underestimated.

The Operational Cost Nobody Talks About

Consider a simple operation: recording a payment. In a monolith, this is a method call:

# Monolith: one process, one database, one transaction
class PaymentsController < ApplicationController
  def create
    payment = Payment.create!(payment_params)
    Invoice.find(payment.invoice_id).mark_paid!
    NotificationMailer.payment_received(payment).deliver_later
    AuditLog.record(:payment_created, payment)

    render json: payment, status: :created
  end
end

Four operations, one request, one database transaction. If any step fails, the transaction rolls back. The code is straightforward to write, straightforward to test, and straightforward to debug.

Now consider the same operation in a microservices architecture:

# Microservices: four services, four databases, eventual consistency
class PaymentsController < ApplicationController
  def create
    payment = PaymentService.create(payment_params)

    # Synchronous call to billing service
    response = BillingClient.mark_invoice_paid(
      payment.invoice_id,
      idempotency_key: SecureRandom.uuid
    )
    raise BillingServiceError unless response.success?

    # Asynchronous event for notification service
    EventBus.publish("payment.created", {
      payment_id: payment.id,
      user_id: payment.user_id,
      amount: payment.amount
    })

    # Asynchronous event for audit service
    EventBus.publish("payment.created.audit", {
      payment_id: payment.id,
      action: :created,
      timestamp: Time.current.iso8601
    })

    render json: payment, status: :created
  rescue BillingClient::TimeoutError
    # What do we do? Payment is recorded but invoice is not marked paid.
    # Retry? Compensate? Queue for later?
    CompensationJob.perform_later(:payment_billing_sync, payment.id)
    render json: payment, status: :accepted  # 202, not 201
  rescue EventBus::PublishError
    # Payment and invoice are updated but notifications may not fire.
    # Is this acceptable? Depends on the business rules.
    FailedEventJob.perform_later("payment.created", payment.id)
    render json: payment, status: :created
  end
end

The same four operations now involve network calls, serialisation, idempotency keys, timeout handling, compensation logic, and eventual consistency. The code is three times longer, but more importantly, the failure modes have multiplied. What happens when the billing service is down? What happens when the event bus loses a message? What happens when the compensation job fails?

graph LR
    subgraph "Monolith"
        M1["Controller"] --> M2["Payment"]
        M1 --> M3["Invoice"]
        M1 --> M4["Mailer"]
        M1 --> M5["AuditLog"]
    end

    subgraph "Microservices"
        S1["Payment Service"] -->|"HTTP"| S2["Billing Service"]
        S1 -->|"Event Bus"| S3["Notification Service"]
        S1 -->|"Event Bus"| S4["Audit Service"]
        S2 -->|"timeout?"| S5["Compensation Job"]
        S3 -->|"lost message?"| S6["Retry Queue"]
    end

    style M1 fill:#27ae60,stroke:#1e8449,color:#fff
    style M2 fill:#27ae60,stroke:#1e8449,color:#fff
    style M3 fill:#27ae60,stroke:#1e8449,color:#fff
    style M4 fill:#27ae60,stroke:#1e8449,color:#fff
    style M5 fill:#27ae60,stroke:#1e8449,color:#fff
    style S1 fill:#e8a838,stroke:#b07828,color:#fff
    style S2 fill:#e8a838,stroke:#b07828,color:#fff
    style S3 fill:#e8a838,stroke:#b07828,color:#fff
    style S4 fill:#e8a838,stroke:#b07828,color:#fff
    style S5 fill:#d9654a,stroke:#8a3a2c,color:#fff
    style S6 fill:#d9654a,stroke:#8a3a2c,color:#fff

Every arrow in the microservices diagram is a potential failure point. Every potential failure point needs handling code, monitoring, alerting, and runbooks.

Companies That Came Back

The most compelling argument for starting with a monolith comes from companies that tried microservices and came back:

Amazon Prime Video published a case study in 2023 describing how they moved from a distributed microservices architecture to a monolith for their video quality monitoring tool – and reduced costs by 90% while improving throughput. The distributed architecture created bottlenecks at service boundaries that vanished when the code ran in a single process.

Segment famously migrated from a microservices architecture back to a monolith after discovering that the operational overhead of managing 120+ microservices was consuming more engineering time than feature development. Their CTO wrote candidly about how the microservices architecture that was supposed to enable faster development had become a tax on every team.

Istio, the service mesh project, consolidated from multiple microservices into a single binary called Istiod. Their blog post explained that the microservices architecture added operational complexity without meaningful benefits at their scale.

Shopify – one of the largest Rails applications in the world – chose a modular monolith over microservices. They invested heavily in Packwerk and component-based architecture rather than splitting into services. Their reasoning: the cost of network boundaries was not justified by the organisational benefits.

Engines as a Stepping Stone

The modular monolith gives you the best of both worlds. You get the organisational benefits of clear boundaries – team ownership, independent development, focused testing – without the operational cost of distribution.

And critically, engines preserve the option to extract services later. An engine with a clean interface can become a microservice when (and only when) the operational cost is justified by a genuine need.

graph LR
    A["Monolith<br/>(everything in app/)"] -->|"Step 1:<br/>Add structure"| B["Modular Monolith<br/>(engines)"]
    B -->|"Step 2:<br/>Only if needed"| C["Selective Extraction<br/>(one engine becomes<br/>a service)"]
    C -->|"Step 3:<br/>Rarely needed"| D["Distributed System<br/>(multiple services)"]

    style A fill:#d9654a,stroke:#8a3a2c,color:#fff
    style B fill:#27ae60,stroke:#1e8449,color:#fff
    style C fill:#e8a838,stroke:#b07828,color:#fff
    style D fill:#4a90d9,stroke:#2c5f8a,color:#fff

Most applications never get past step 2. And that is perfectly fine. The goal is not to arrive at microservices. The goal is to have a codebase that is maintainable, testable, and adaptable to whatever comes next.

The Decision Framework

When the microservices conversation comes up – and it will – use this framework:

flowchart TD
    A["Do you have a scaling problem<br/>that cannot be solved with<br/>vertical scaling?"] -->|Yes| B["Is the problem isolated<br/>to a specific domain?"]
    A -->|No| C["Stay with<br/>modular monolith"]

    B -->|Yes| D["Do you have the team<br/>and infrastructure to<br/>operate a distributed system?"]
    B -->|No| C

    D -->|Yes| E["Extract that one<br/>domain as a service"]
    D -->|No| F["Invest in infrastructure<br/>first, extract later"]

    style A fill:#e8a838,stroke:#b07828,color:#fff
    style B fill:#e8a838,stroke:#b07828,color:#fff
    style C fill:#27ae60,stroke:#1e8449,color:#fff
    style D fill:#e8a838,stroke:#b07828,color:#fff
    style E fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style F fill:#8e44ad,stroke:#6c3483,color:#fff

The framework is deliberately conservative. Each “No” sends you back to the monolith because the default should be the simpler architecture. You only move to a distributed system when you have a specific, measurable problem that cannot be solved any other way, and the team and infrastructure to support it.

Start with a modular monolith. Structure it well. Extract when – and only when – the evidence demands it.


This was adapted from Chapter 17 of Modular Rails: Architecture for the Long Game. The book covers the full microservices question including network latency, debugging, data consistency, and the complete decision framework.

For the bigger picture — engines, Packwerk, data ownership and the full set of trade-offs in one place — see The Modular Monolith in Rails.

Read the entire book free on the web — every chapter, no paywall. Prefer print or Kindle? Amazon US · Amazon UK · all editions & prices.