Skip to content
XGitHubEmail

Engineering

Rails 8 Engine Architecture: Building a Monolith That Scales

Why we chose a single Rails monolith with domain engines instead of microservices — and how we structure it so the codebase stays manageable as we grow.

KK
Kudapara Kady· Founder & Engineer
Mar 20, 2026·10 min read
railsarchitecturemonolithengines

Everyone says “start with a monolith.” But nobody tells you how to structure it so it doesn’t become a tangled mess of coupled concerns. We chose Rails 8 with a domain engine architecture. Here’s what that looks like.

The Problem with “Just a Monolith”

A monolith without structure is just a big ball of mud. Every model knows about every other model. Every controller can call any service. The codebase grows linearly but its complexity grows quadratically.

The alternative — microservices — solves the coupling problem but introduces distributed systems complexity. Network calls instead of method calls. Eventual consistency instead of transactions. Deploy pipelines instead of one command.

We wanted the simplicity of a monolith with the boundary discipline of microservices. Rails engines gave us that.

The Architecture

Our app is structured as a single Rails 8 application with domain engines:

app/
  models/
  controllers/
  views/
engines/
  logistics/
    app/
      models/
      controllers/
      services/
    config/
    lib/
    spec/
  commerce/
    app/
    ...
  messaging/
    app/
    ...
  ai/
    app/
    ...

Each engine is a self-contained domain with its own models, controllers, services, and tests. The main app is a thin shell — it loads the engines and provides shared infrastructure.

Defining Engine Boundaries

The key question: where do engine boundaries go? We use domain-driven design principles:

  1. Logistics handles routing, fleet management, delivery tracking
  2. Commerce handles products, orders, payments
  3. Messaging handles WhatsApp, email, in-app notifications
  4. AI handles Muchi AI assistant capabilities

Each engine has clear inputs and outputs. Logistics doesn’t know about commerce internals — it just receives delivery orders and returns results.

The Engine Contract

# engines/logistics/lib/logistics/engine.rb
module Logistics
  class Engine < ::Rails::Engine
    isolate_namespace Logistics

    config.autoload_paths += %W[
      #{root}/app/services
    ]
  end
end

isolate_namespace is the critical line. It means:

  • Models live in Logistics::DeliveryScenario, not DeliveryScenario
  • Controllers are namespaced as Logistics::ScenariosController
  • Routes are scoped under /logistics/
  • Migrations are namespaced and tracked separately

This isolation prevents the most common monolith problem: one developer’s domain change breaking another developer’s code.

Cross-Engine Communication

Engines communicate through well-defined interfaces. We use a simple event bus pattern:

# engines/logistics/app/services/logistics/event_bus.rb
module Logistics
  EventBus = ActiveSupport::Notifications

  def self.publish(event_name, payload)
    EventBus.instrument("logistics.#{event_name}", payload)
  end

  def self.subscribe(event_name, &block)
    EventBus.subscribe("logistics.#{event_name}") do |*args|
      block.call(*args)
    end
  end
end

When a delivery completes, Logistics publishes:

Logistics.publish('delivery_completed', {
  scenario_id: scenario.id,
  rider_id: rider.id,
  completed_at: Time.current
})

Commerce listens:

Logistics.subscribe('delivery_completed') do |event|
  Commerce::OrderCompletionService.new(event.payload[:scenario_id]).complete!
end

This keeps engines decoupled. Commerce doesn’t know about the Logistics model internals — it just knows about the event payload.

Shared Infrastructure

Some things live in the main app, not in engines:

  • Authentication and authorization
  • The main layout and navigation
  • Shared utilities (pagination, money formatting)
  • The API gateway layer

This prevents the common mistake of extracting everything into engines prematurely. The boundary between “shared infrastructure” and “domain engine” should be deliberate.

API Routes

# config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: 'json' } do
    namespace :v1 do
      scope :logistics do
        resources :scenarios
        resources :riders
        resources :deliveries
      end

      scope :commerce do
        resources :products
        resources :orders
        resources :payments
      end

      scope :messaging do
        resources :messages
        resources :conversations
      end
    end
  end
end

All API routes are scoped under /api/v1/<domain>/. This is non-negotiable. It means:

  • Clients can’t accidentally couple to internal paths
  • Versioning is clean — /api/v2/ can introduce breaking changes
  • Domain boundaries are visible in the URL structure

Testing Strategy

Each engine has its own test suite. Tests run in isolation:

cd engines/logistics && bundle exec rspec

This means a Logistics change can’t break Commerce tests without going through the event bus. If it does, you’ve found a boundary violation.

The Trade-Offs

This architecture isn’t free. The costs:

  • More boilerplate: Each engine has its own Gemfile, engine.rb, and initializers.
  • Learning curve: New developers need to understand engine boundaries, not just Rails conventions.
  • Overhead for small teams: If you’re building a CRUD app with 3 models, this is overkill.

But for a team of 5-15 people building multiple domains (logistics, commerce, messaging, AI), the structure pays for itself. Every engineer knows where their code lives. Every change is contained. Every test failure is localized.

When to Extract a Service

The rule is simple: extract a service when you need to scale it independently of the rest of the app. If the logistics engine needs 10x more compute than everything else, extract it. Until then, it stays in the monolith.

This isn’t about purity. It’s about solving real problems with real tools. The monolith with engines solves our problem: a growing team building multiple domains without the overhead of distributed systems.

The Result

We’re 18 months in. The codebase has 120k lines of code across 4 engines. Onboarding takes a day. Every new feature fits cleanly into an existing engine. No one is afraid to change anything, because every change is contained.

That’s what a well-structured monolith looks like. Not a big ball of mud. A set of rooms with doors between them.

KK

Kudapara Kady

Founder & Engineer

Building software for Africa at Kudapara. Engineering, AI, and logistics from the ground.