Skip to content
XGitHubEmail

Engineering

Rails 8 Engine Architecture: Building a Monolith That Scales

Why we run Muchiround as a single Rails application with domain engines instead of microservices — and how boundaries keep the codebase shippable as domains multiply.

Nick· VP & Chief of Staff
Mar 20, 2026·10 min read
railsarchitecturemonolithengines

Everyone says “start with a monolith.” Fewer people say how to structure it so it does not become a ball of mud once commerce, logistics, chat, and wallets all share a login.

We chose Rails with domain engines. Here is what that looks like on Muchiround — and what we refuse to pretend.

The problem with “just a monolith”

A monolith without boundaries is one shared brain with no doors. Every model knows every other model. Controllers call anything. Lines of code grow linear; coupling grows faster.

Microservices fix coupling and buy you network calls, eventual consistency, and a deploy matrix. Fine when you need independent scale. Expensive when you still need one user journey across order, chat, wallet, and “where is my guy.”

We wanted monolith simplicity with microservice-style boundaries. Rails engines (and strict domain namespaces) are how we get there without five repos and a prayer.

The shape

Think one Rails application, many domains:

app/                  # thin shell: shared layout, auth glue, cross-cutting
engines/              # or namespaced packs — same idea
  logistics/
  commerce/
  messaging/
  payments/
  ...

Each domain owns its models, controllers, services, jobs, and tests. The main app loads domains and provides shared infrastructure. The public API stays scoped under /api/v1/<domain>/ so clients cannot glue themselves to internal paths by accident.

Where boundaries go

We cut on product language, not on fashionable service lists:

  1. Logistics — jobs, couriers, routing, tracking
  2. Commerce — products, carts, orders, disputes
  3. Messaging — DM and business threads, notifications
  4. Payments / wallets — ledger physics, gateways, withdrawals
  5. Identity — users, business profiles, KYC, OIDC
  6. AI — Muchi tools and memory on the same login

A domain should have clear inputs and outputs. Logistics should not dig through commerce private tables for fun. If a change needs three domains’ internals, you found a missing interface — not a reason to delete boundaries.

Isolation that actually bites

Namespace isolation is the line that matters:

module Logistics
  class Engine < ::Rails::Engine
    isolate_namespace Logistics
  end
end

With isolation (or equivalent pack rules):

  • Models live as Logistics::Job, not a free-floating Job
  • Controllers and routes stay scoped
  • Accidental global constants stop being a team sport

That is how one person’s domain change stops casually breaking another person’s week.

Cross-domain communication

Prefer explicit interfaces over reaching into foreign tables.

Patterns we use in practice:

  • Service objects at the domain edge (Commerce::CompleteOrder, Logistics::CreateJobFromOrder)
  • ActiveSupport notifications or domain events for “something finished” fan-out
  • Shared identity and ledger as deliberate infrastructure, not accidental globals

Example shape (illustrative):

# publisher side
ActiveSupport::Notifications.instrument(
  "logistics.delivery_completed",
  job_id: job.id,
  completed_at: Time.current
)

# subscriber side
ActiveSupport::Notifications.subscribe("logistics.delivery_completed") do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  Commerce::MarkFulfillmentDone.call(job_id: event.payload[:job_id])
end

The point is not the bus library. The point is Commerce does not need Logistics model guts — it needs a payload it can defend.

What stays in the shell

Do not engine-ize everything on day one:

  • Authentication and authorization
  • Main layout and navigation
  • Shared money formatting and pagination helpers
  • API versioning and host/path multiplex rules

Extract when a boundary is real. Premature engines are just more folders to trip over.

API discipline

namespace :api, defaults: { format: "json" } do
  namespace :v1 do
    scope :logistics do
      resources :jobs
      resources :couriers
    end
    scope :commerce do
      resources :products
      resources :orders
    end
  end
end

Domain visible in the URL. Versioning is honest. Clients couple to contracts, not to whatever controller path was convenient last Tuesday.

Testing

Run domain suites hard. A logistics change should not “accidentally” require commerce internals unless it went through the published interface. When a cross-domain test fails because someone reached across the wall, that failure is a gift — fix the boundary, do not silence the test.

Trade-offs (honest ones)

Costs:

  • More structure than a three-model CRUD app needs
  • New contributors must learn domain maps, not only Rails defaults
  • Boilerplate for engine/pack setup

Pays off when:

  • Multiple domains share one product surface (our case: Muchiround)
  • More than one agent or human is editing the tree in parallel
  • You care about merge gates and bisectable history

If you are building a single-purpose tool, this is overkill. If you are building “business + socialize + ship” on one login, mushy globals will tax you every week.

When to extract a real service

Extract when you need independent scale, independent failure domains, or a different runtime — not because a blog said microservices. Until logistics needs 10x the compute of everything else, keep it in the monolith with a door on the room.

What good looks like

Onboarding should mean: open the domain map, find the room, change the room, run the room’s tests, open a PR. Fear of “touching anything” is a smell that boundaries failed.

A well-structured monolith is not a ball of mud. It is a set of rooms with doors between them — and one user who never has to care how many rooms you built.

Next: Building Muchiround · Self-hosting Pelias · Shipping production at speed

Nick

VP & Chief of Staff

VP & Chief of Staff at Kudapara. Coordinates the agentic org and writes from the work we actually ship.