← Back to homebackend

The Modular Monolith: Why Your Next Backend Probably Shouldn't Be Microservices

← All writing

The most expensive architecture mistake we see at Luminary isn't messy code. It's a five-person team operating a twenty-service distributed system: a Kubernetes cluster full of pods that each do one small thing, a tracing stack nobody fully understands, a deploy pipeline per repo, and a feature velocity that has quietly fallen off a cliff. The code in each service is often fine. The system around the code is the problem. When we're asked to review a backend like this, the recommendation is almost always some version of the same thing: you don't need fewer boundaries — you need cheaper ones. That's what a modular monolith is: the boundaries of microservices with the physics of a single process. It's the default we reach for on nearly every new backend we build, and this post is the long version of why.

Illustration: a great hall divided into clean glass-walled departments

The microservices hangover

Microservices were never primarily a technical pattern. They're an organizational one: they let many teams deploy independently without stepping on each other. That's a real benefit — if you have many teams. What the industry spent a decade doing was adopting the org structure of a two-thousand-engineer company at companies with eight engineers.

The bill for that comes due as the distributed-systems tax, and it's itemized:

  • Every function call that becomes a network call inherits latency, timeouts, retries, and partial failure. A null check becomes a circuit breaker.
  • Transactions across capabilities stop being BEGIN ... COMMIT and become sagas, compensation logic, and reconciliation jobs.
  • Refactoring across a service boundary means versioned APIs, backward-compatible rollouts, and coordination meetings — for a change that would have been a rename inside one repo.
  • The operational surface multiplies: N pipelines, N dashboards, N on-call runbooks, N sets of dependencies to patch, plus the connective tissue (service discovery, mTLS, a message broker, distributed tracing) that exists only because you cut the system apart.

And then there's org-chart-driven architecture. Conway's law says your system will mirror your communication structure; microservices advocates turned that into a design tool ("align services to teams"). The failure mode is running it in reverse: a small team ships thirty services and ends up with the architecture of an org they don't have — and every request now traverses six "team boundaries" owned by the same two people.

None of this is hypothetical. The retreat stories are public and well documented:

  • Amazon Prime Video (2023): the Video Quality Analysis team published a post explaining how their audio/video monitoring service — originally built as distributed components orchestrated with AWS Step Functions and Lambda — hit both cost and scaling walls. They consolidated it into a single process and cut infrastructure costs by over 90%, while handling more streams. The fine print matters: the process boundaries forced expensive S3 round-trips and orchestration-state overhead for what was fundamentally one data-heavy pipeline. The boundaries themselves were the cost.
  • Segment (2018): their aptly titled "Goodbye Microservices" post described running 140+ destination services — one per integration — and drowning in the overhead: a shared-library change meant redeploying everything, on-call was constant queue-firefighting, and testing was misery. They consolidated back into a single service fed by one queueing system (Centrifuge).
  • Istio (2020): the service-mesh project itself — the software you install to make microservices bearable — consolidated its own control plane from multiple services into one binary, istiod, in release 1.5, because the operational complexity of running it as separate services wasn't paying for itself.
  • Shopify never left. One of the largest Rails codebases in existence runs as a modular monolith: explicit components with dependency rules enforced by tooling they built for the job (Packwerk). They've written extensively about choosing enforced modularity over decomposition.

To be fair to the pattern: microservices work superbly at Netflix, Uber, and Amazon's retail platform — organizations with hundreds of teams where deploy independence is existential. The failures above aren't "microservices are bad." They're mismatches between the architecture's cost structure and the organization paying for it.

What a modular monolith actually is

A modular monolith is one deployable artifact containing several strictly bounded modules. Three properties make it real rather than aspirational:

  1. Each module has a public API — a small, explicit surface (an interface, a facade, an events contract) that is the only way other modules interact with it.
  2. Everything else is private and the privacy is enforced — by the compiler, a linter, or an architecture test that fails CI. Convention is not enforcement.
  3. Each module owns its data. No other module touches its tables. Cross-module data access goes through the module's API or through events, full stop.

Note what this is not. It's not a "well-organized monolith" — every big ball of mud started as one of those. The difference is mechanical enforcement: an import that reaches into another module's internals should fail the build, not a code review vibe check. And it's not the classic layered architecture (controllers/services/repositories as horizontal strata); modules are vertical slices by business capabilitycatalog, ordering, billing, identity — each containing its own full stack.

The mental model we use with clients: it's a microservices architecture where the network has been replaced by function calls and the broker by a table. All of the design discipline, none of the physics.

Enforcing boundaries in practice

Package structure

The layout should make the rules legible. Ours typically looks like this:

src/
  app/                     # composition root: config, HTTP wiring, DI
  shared/                  # cross-cutting kernel: logger, Result, EventBus, Db
  modules/
    catalog/
      index.ts             # public API — the only legal import surface
      internal/
        catalog.service.ts
        product.repo.ts
        migrations/        # migrations for the "catalog" DB schema
    ordering/
      index.ts
      internal/ ...
    billing/
      index.ts
      internal/ ...
    identity/
      index.ts
      internal/ ...

app/ is the only place that knows about all modules; it constructs them and hands each one the dependencies (and other modules' APIs) it's allowed to use. shared/ is deliberately boring — infrastructure and base types only, never business logic. Business logic in shared/ is how modules start coupling through the back door.

A module's public API

In TypeScript, a module's index.ts exports an interface, DTO types, event contracts, and a factory — and nothing from internal/:

// modules/billing/index.ts — the ONLY file other code may import from billing

import type { Db } from "@/shared/db";
import type { EventBus } from "@/shared/events";
import { BillingService } from "./internal/billing.service";

export interface InvoiceSummary {
  id: string;
  orderId: string;
  status: "draft" | "issued" | "paid" | "void";
  totalCents: number;
}

export interface BillingApi {
  createInvoiceForOrder(orderId: string): Promise<InvoiceSummary>;
  getInvoice(invoiceId: string): Promise<InvoiceSummary | null>;
}

export function createBillingModule(deps: {
  db: Db;
  events: EventBus;
}): BillingApi {
  const service = new BillingService(deps.db, deps.events);

  // Billing decides what it reacts to. Ordering publishes "order-placed"
  // without knowing billing exists — same decoupling as a broker, in-process.
  deps.events.subscribe("ordering.order-placed", (e) =>
    service.createInvoiceForOrder(e.orderId),
  );

  return service;
}

Two details worth copying. The API trades in plain DTOs, not ORM entities — handing out a live entity leaks your persistence model and invites callers to mutate state they don't own. And the API is coarse-grained: createInvoiceForOrder(orderId), not seven chatty getters. Coarse interfaces survive being put behind a network later; chatty ones don't.

Making the compiler and linter do the policing

Enforcement is the whole game. In a TypeScript codebase, dependency-cruiser (or ESLint boundary plugins, or Nx module-boundary tags in a monorepo) turns the rules into CI failures:

// .dependency-cruiser.cjs (excerpt)
module.exports = {
  forbidden: [
    {
      name: "module-internals-are-private",
      comment: "Modules may only be imported via their index.ts",
      severity: "error",
      from: { path: "^src/modules/([^/]+)/" },
      to: {
        path: "^src/modules/",
        pathNot: ["^src/modules/$1/", "^src/modules/[^/]+/index\\.ts$"],
      },
    },
    {
      name: "shared-imports-no-modules",
      severity: "error",
      from: { path: "^src/shared/" },
      to: { path: "^src/modules/" },
    },
  ],
};

Every mainstream ecosystem has an equivalent: ArchUnit and Spring Modulith on the JVM, Packwerk for Ruby, internal/ packages in Go, architecture tests in .NET. The tool matters less than the property: a boundary violation is a build failure, not a review comment.

One database, a schema per module

The database is where monoliths traditionally rot, because a shared table is an invisible API with unlimited consumers. The rule set we apply:

  • Each module gets its own schema (catalog.*, ordering.*, billing.*) in one physical Postgres, with migrations owned and versioned by that module.
  • No cross-schema foreign keys and no cross-schema joins in module code. If ordering needs product data, it calls CatalogApi — or keeps its own denormalized copy, updated by events.
  • Reporting and analytics — the classic excuse for join-everything queries — go to a read replica or a warehouse, never through module code.

This feels ceremonious on day one and pays for itself for years: it keeps every module honest about its dependencies, and it's precisely what makes extraction possible later. Shared-table coupling is the number-one reason "we'll split the monolith someday" turns out to be impossible.

Async messaging inside a monolith: the outbox pattern

Events aren't a microservices thing; they're a decoupling thing. Even in one process, ordering shouldn't synchronously call billing, notifications, and analytics after placing an order — it should announce that an order was placed and let subscribers react.

The classic hazard is atomicity: you commit the order, then the process dies before the event fires, and billing never hears about it. Microservices teams solve this with the transactional outbox, and it works even better inside a monolith because the outbox lives in the same database as your business writes:

CREATE TABLE ordering.outbox (
  id            BIGSERIAL    PRIMARY KEY,
  event_type    TEXT         NOT NULL,      -- e.g. 'ordering.order-placed'
  aggregate_id  UUID         NOT NULL,
  payload       JSONB        NOT NULL,
  created_at    TIMESTAMPTZ  NOT NULL DEFAULT now(),
  processed_at  TIMESTAMPTZ
);

CREATE INDEX outbox_unprocessed_idx
  ON ordering.outbox (id)
  WHERE processed_at IS NULL;

Placing an order inserts the order row and the outbox row in the same transaction — so the event exists if and only if the order does. A relay worker (a background loop in the same process, or a separate instance of the same artifact) drains it:

SELECT * FROM ordering.outbox
WHERE processed_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;

...and dispatches each event to in-process handlers, marking rows processed on success. Delivery is at-least-once, so handlers are idempotent — keyed on the outbox id.

Notice what you didn't need: Kafka, RabbitMQ, or any broker at all. A table plus SKIP LOCKED handles a surprising amount of throughput, and because the EventBus is an interface, swapping the implementation for a real broker later is a wiring change, not a rewrite. You get to practice the exact discipline distributed systems demand — idempotency, eventual consistency between modules, event contracts — while debugging remains "put a breakpoint in the handler."

When microservices genuinely earn their cost

We're not monolith absolutists. Extraction is the right call when a specific, observed pressure shows up:

  • Divergent scaling shape, not just volume. A CPU-pinned media pipeline next to an IO-bound API, or an ML inference path that wants GPUs. Scaling the whole artifact means paying for the expensive resource everywhere. (Volume alone doesn't qualify — replicas handle volume.)
  • Team autonomy at real scale. When several teams measurably block each other — deploy trains queue up, incident blast radius crosses team lines — deploy independence starts paying for its overhead. This tends to appear at multiple-teams scale, not multiple-engineers scale.
  • Polyglot needs. A genuine requirement for a different runtime: Python for the ML team, Go for a network-heavy edge component. "The new hire prefers Elixir" is not a requirement.
  • Compliance and isolation. PCI-scope minimization, data-residency walls, or running semi-trusted workloads (user plugins, code execution) where a process boundary is a security boundary, not an aesthetic one.
  • Independent failure domains. A best-effort recommendations path that must never take checkout down with it.

The honest comparison looks like this:

ConcernModular monolithMicroservices
Deploy unitOne artifact, one pipelinePer service — independent, but N pipelines
Cross-capability transactionBEGIN ... COMMITSagas, outboxes, reconciliation
Refactor across a boundaryCompiler-checked renameVersioned API migration
Call between capabilitiesFunction call (~ns)Network hop (~ms) + failure modes
Scaling granularityWhole artifact (roles help)Per service
Team deploy autonomyShared cadenceFully independent
Operational surfaceOne service to runFleet + mesh + broker + tracing
Wrong-boundary costMove a folder, fix importsRewrite APIs, migrate data, coordinate teams

That last row is the one we weight most heavily. You will get some boundaries wrong — everyone does, because domain understanding arrives after you've built the thing. In a monolith a wrong boundary costs an afternoon; across services it costs a quarter.

Design for extraction: the strangler fig is the exit

The strongest argument for the modular monolith is that it's also the best possible preparation for microservices. If your boundaries are honest, extracting a module is mechanical:

  1. The module already has a coarse API → put an HTTP/gRPC surface in front of the same interface.
  2. The module already owns its schema → move that schema to its own database; nothing else was touching it.
  3. Events already flow through an outbox → point the relay at a real broker instead of the in-process bus.
  4. Swap the in-process implementation of the module's API for a thin network client behind the same TypeScript interface. Callers don't change.
  5. Strangler-fig the traffic: route a percentage to the extracted service, watch it, ramp up, delete the in-process path.

Everything that blocks this in real codebases is exactly what the module rules banned: cross-schema joins, shared mutable state, entity types leaking across boundaries, chatty synchronous interfaces, distributed-transaction assumptions. Follow the rules and you haven't built a monolith you'll be stuck with — you've built a service architecture that happens to deploy as one binary until the day one seam needs to be physical.

The deployment story (and the scaling myth)

One artifact means one pipeline: build, test, roll out blue/green or rolling, done. No deploy-ordering matrix, no "service A must ship before B," no compatibility window between your own components.

The persistent myth is that monoliths can't scale. It conflates two unrelated things: the deployment unit and runtime scaling. A stateless monolith scales horizontally exactly like a stateless microservice — run twelve replicas behind a load balancer instead of three. What you lose is granularity: every replica carries every module, including cold ones. In practice that memory overhead is usually far cheaper than a fleet of under-utilized pods each paying its own runtime, sidecar, and connection-pool overhead — Prime Video's 90% number was largely this arithmetic.

You can also claw back most of the granularity without splitting the codebase: role-based deployment. Ship the same artifact everywhere and use an env flag to decide what a given instance runs — ROLE=web serves HTTP, ROLE=worker runs the outbox relay and background jobs. Same build, same code, independently scalable pools. And as an existence proof for raw capability: Stack Overflow has served its enormous traffic for years on a famously small fleet of servers running a monolithic application. The ceiling is higher than almost anyone's actual traffic.

Observability and testing: the quiet wins

These rarely make the architecture-decision slide deck, and they dominate day-to-day experience:

  • A stack trace crosses every boundary. When checkout fails, the trace shows ordering → billing → payment in one frame stack. No distributed tracing needed to reconstruct causality; a profiler answers "why is this slow" directly.
  • A debugger works end-to-end. Breakpoint in ordering, step into billing. Try that across pods.
  • One log stream, one dashboard, one alert set. Correlation IDs become nice-to-have instead of survival gear.
  • Integration tests run in one process against one database. Spin up Postgres, wire the modules, exercise a full order-to-invoice flow in milliseconds. No docker-compose file with twelve services, no flaky test environments, no contract-test matrix — the compiler is your contract test. Change a module's API and every consumer fails the build immediately, instead of failing in staging three days later.

Teams underestimate how much of their microservices toolchain exists purely to reconstruct information the monolith gives you for free.

A decision framework

When a client asks "monolith or microservices?", we walk through this:

  1. Default to the modular monolith if you're one product team (or a few), on primarily one language, with no hard isolation mandate. At under a few dozen engineers, this is almost always the answer.
  2. Interrogate every claimed exception with evidence, not prediction:
    • Which two modules need a different scaling shape — and what measurement shows it?
    • Are deploys blocking each other today — how often, costing how much?
    • Is there a regulatory or security boundary that must be a process boundary?
    • Is a second runtime genuinely required, or preferred?
  3. A "yes" justifies extracting that one seam — not exploding the system. Most companies that are honest about this end up with a modular monolith plus two or three satellite services, which is a perfectly respectable end state, not a transitional embarrassment.
  4. Whatever you choose, enforce boundaries mechanically from day one. The monolith-vs-microservices debate is downstream of a more important property: whether your boundaries are real. A modular monolith keeps them real at function-call prices.

Takeaways

  • Microservices solve an organizational problem — deploy independence for many teams. Adopting them without that problem means paying the distributed-systems tax for nothing.
  • The retreats are real and public: Prime Video's monitoring team cut infra costs over 90% by consolidating to a single process; Segment folded 140+ services back into one; Istio merged its own control plane into istiod; Shopify scaled a modular monolith instead of leaving it.
  • A modular monolith = one deployable, vertical modules by business capability, a small public API per module, and mechanically enforced privacy — lint/compiler failures, not review comments.
  • Give each module its own database schema; ban cross-schema joins and shared tables. It's the single rule that keeps future extraction possible.
  • Use the outbox pattern in-process: business write + event in one transaction, a SKIP LOCKED relay, idempotent handlers. Broker optional, swappable later.
  • Extract a service only for observed pressure: scaling shape, real team contention, polyglot need, or compliance isolation — and extract one seam via strangler fig, not everything.
  • Monoliths scale horizontally like anything stateless; use role-based deployment (same artifact, ROLE=web / ROLE=worker) for granularity.
  • Wrong boundaries are inevitable; choose the architecture where fixing one costs an afternoon, not a quarter.

Enjoyed the read? We build this stuff for clients too.

Start a project