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.

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:
- Logistics handles routing, fleet management, delivery tracking
- Commerce handles products, orders, payments
- Messaging handles WhatsApp, email, in-app notifications
- 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, notDeliveryScenario - 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.
Kudapara Kady
Founder & Engineer
Building software for Africa at Kudapara. Engineering, AI, and logistics from the ground.
Keep Reading

Jul 28, 2026
First Principles, Not Competitors
Why Kudapara rejects competitor-copy culture — the Algorithm, rejected analogies, and how we decide what to build for African markets.

Jul 28, 2026
Not an agency. Not a clone.
How Kudapara differs from consultancies, single-founder shops, and startups that ship Western software with a local skin.

Jul 28, 2026
Production-ready. Still fast.
How Kudapara ships with Hermes agents, Kanban handoffs, PR discipline, local CI merge gates, and the Algorithm before automation.