What a majestic monolith is (and what it is not)

A majestic monolith is one codebase that builds into one deployable artifact, usually backed by one primary database. Its code is organized by shared conventions rather than per-project configuration, in the Rails-style tradition: a predictable place for models, controllers, jobs and tests, so an engineer who knows one corner of the app can find their way around the rest. The "majestic" part is intent. The team has decided that a single deployable is the right unit of release and has invested in keeping its internals clean.

That separates it from the monolith most engineers have suffered through: the big ball of mud. A ball of mud is also one deployable, but no one chose its shape. It grew by accretion. Any class can reach into any table, and every change risks a distant regression.

Property Majestic monolith Big ball of mud Microservices
Deployable units One One Many
Internal boundaries Explicit, convention-enforced Absent or eroded Enforced by the network
Data ownership Per module, one database Shared by everything Per service, many stores
Cross-boundary calls In-process method calls Arbitrary coupling Network requests
Operational surface Small Small Large

This approach suits most product teams, small to mid-sized engineering organizations, and platform teams that want fewer moving parts to deploy, monitor and page on. It gets most of the benefit of modularity without paying for distribution.

Why teams choose it: the real cost of distribution

The cost of splitting a system into services rarely shows up in the architecture diagram. Every boundary that crosses a process turns a function call into a network call. That call can time out, partially succeed, or succeed twice, so it needs retries, idempotency keys and timeouts. A write that touches two services can no longer share one database transaction. It becomes a saga with compensating actions that someone has to design, test and debug.

Concern Inside one deployable Across services
Calling another module In-process function call Network request with timeouts and retries
Multi-entity write One database transaction Saga or outbox with compensation
Changing an interface Refactor callers in one commit Version the API and coordinate rollout
Build and deploy One pipeline One pipeline per service
Debugging a request One stack trace, one log stream Distributed tracing across hops
On-call scope One application Every service and the links between them

Conventions as a substitute for coordination

A rails-style monolith replaces many of these cross-team agreements with shared defaults. A consistent directory layout tells you where controllers, jobs and domain logic live. Naming rules let you predict a file's location from a class name. Framework conventions settle how to handle validation, background work and database access. An engineer moving from billing to search reads the same structure, so they skip a separate onboarding for each part of the codebase. Questions such as which serialization format to use, how to report errors or how to log were answered once. Teams don't have to negotiate them again for every new service.

How it works: modular boundaries and data ownership inside one deployable

Those shared conventions only pay off if the code inside the deployable has a shape. A majestic monolith gets that shape by organizing code around business domains such as billing, catalog and accounts, not around technical layers like controllers, models and services. When a change to invoicing touches one directory instead of five, the domain becomes the unit of reasoning, review and ownership.

Each module exposes a narrow public interface: a small set of functions or service objects that other modules may call. Everything else, including models, queries and helpers, stays private. The interface is a contract. The internals can be rewritten freely as long as the contract holds.

Data follows the same rule. Every table has exactly one owning module. Other modules may not join against it, read it directly or write to it. They ask the owner through its interface. This feels restrictive in a single database where a join is one line away. But a cross-module join hides coupling that no interface reveals, and a write that bypasses the owner skips its validations and invariants.

+----------------------- one deployable artifact -----------------------+
|                                                                       |
|  [ Accounts ]           [ Catalog ]             [ Billing ]           |
|   public API  <-------   public API  <-------    public API           |
|   (internals)            (internals)             (internals)          |
|       |                       |                       |               |
+-------|-----------------------|-----------------------|---------------+
        v                       v                       v
+-------------------------- shared database ----------------------------+
|  users, sessions    |  products, prices     |  invoices, payments     |
|  (owned: Accounts)  |  (owned: Catalog)     |  (owned: Billing)       |
+-----------------------------------------------------------------------+

These seams are what make a later extraction possible. If Billing only ever reached Catalog through its interface and owned its tables outright, moving it into its own service means replacing in-process calls with network calls and moving a known set of tables. You don't have to untangle years of hidden coupling first.

// modules/accounts/index.ts: the ONLY file other modules may import from accounts.
export { getBillingContact } from "./internal/contacts";
export type { BillingContact } from "./internal/contacts";

// modules/accounts/internal/contacts.ts: private. Accounts owns the users table.
import { db } from "../../../shared/db"; // app-wide connection pool (placeholder)

export interface BillingContact { userId: string; email: string; name: string; }

export async function getBillingContact(userId: string): Promise<BillingContact | null> {
  // Only accounts code queries users, so column renames stay local to this module.
  const row = await db.queryOne<{ id: string; email: string; full_name: string }>(
    "SELECT id, email, full_name FROM users WHERE id = $1 AND deleted_at IS NULL",
    [userId],
  );
  return row ? { userId: row.id, email: row.email, name: row.full_name } : null;
}

// modules/billing/internal/invoices.ts: billing owns invoices and asks accounts for the rest.
import { db } from "../../../shared/db";
import { getBillingContact } from "../../accounts"; // public API, never accounts/internal/*

export async function prepareInvoiceEmail(invoiceId: string) {
  // Forbidden: SELECT ... FROM invoices JOIN users ON ... (hidden cross-module coupling)
  const invoice = await db.queryOne<{ id: string; user_id: string; total_cents: number }>(
    "SELECT id, user_id, total_cents FROM invoices WHERE id = $1",
    [invoiceId],
  );
  if (!invoice) throw new Error(`invoice ${invoiceId} not found`);

  // Two queries instead of one join. This seam later becomes a network call if billing is extracted.
  const contact = await getBillingContact(invoice.user_id);
  if (!contact) throw new Error(`no billing contact for user ${invoice.user_id}`);

  return { to: contact.email, subject: `Invoice ${invoice.id}`, amountCents: invoice.total_cents };
}

Billing reads only its own invoices table and gets user data through the accounts module's public interface instead of joining against users, so the users table and its rules stay inside accounts.

Implementing it: enforcing boundaries, CI, and team workflow

Boundaries that live only in a design doc erode within a few months. The pressure to ship pushes someone to reach into another module's internals "just this once," and a reviewer who lacks context approves it. Make the rule mechanical instead.

Automate dependency rules

Each module declares a public interface, for example a top-level facade or a dedicated api directory. Everything else is private. A CI step parses imports and fails the build when one module references another module's non-public paths. This can be a short script that walks source files and matches import statements against a list of allowed entry points. The check runs on every pull request, so violations surface before review, not after merge. Reviewers can then spend their attention on design rather than policing paths.

Map ownership explicitly

A code ownership file, such as CODEOWNERS, assigns each module directory to a team. Changes to a module's interface then require approval from the team that owns it. This keeps the module's contract stable even though everyone commits to the same repository.

Keep the suite fast

A slow test suite pushes developers toward large batched changes. Split tests across parallel CI workers. Use the dependency graph to run only the tests for the modules a change touches and the modules that depend on them, and run the full suite on the main branch.

Trunk-based development with flags

Merge small changes to trunk daily. Put unfinished work behind feature flags so you can deploy code without exposing it to users. This keeps a single deployable releasable at all times and avoids long-lived branches that are painful to merge.

#!/usr/bin/env python3
# CI check: fail the build when one module imports another module's internals.
# Assumed layout: app/<module>/...; only app.<module>.api is public.
import ast
import pathlib
import sys

ROOT = pathlib.Path("app")
violations = []

for path in ROOT.rglob("*.py"):
    owner = path.relative_to(ROOT).parts[0]  # module this file belongs to
    tree = ast.parse(path.read_text(), filename=str(path))
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            names = [a.name for a in node.names]
        elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
            # Join so 'from app.billing import models' becomes 'app.billing.models'
            names = [f"{node.module}.{a.name}" for a in node.names]
        else:
            continue  # relative imports stay inside the current module
        for name in names:
            parts = name.split(".")
            if parts[0] != "app" or len(parts) < 2 or parts[1] == owner:
                continue  # stdlib, third-party, or same-module import
            if len(parts) >= 3 and parts[2] == "api":
                continue  # going through the public interface is allowed
            violations.append(f"{path}:{node.lineno}: '{owner}' imports '{name}'")

if violations:
    print("Cross-module imports must go through <module>.api:")
    print("\n".join(violations))
    sys.exit(1)  # non-zero exit fails the CI job
print("Module boundaries OK")

A short standard-library Python script, run as a CI step on every pull request, that parses imports and fails the build when code in one module reaches past another module's public api package.

Scaling a single deployable in production

The same discipline carries into production, where the monolith scales by running more copies of one artifact rather than by splitting it.

Horizontal processes from one artifact

Keep web processes stateless. Sessions, uploads, and caches live in shared stores, so you can add instances behind a load balancer without coordination. Background work runs from the same build, started with a different entry point as a worker process consuming a queue. One artifact serving several roles means one version to reason about, and the web and worker tiers can still be sized independently.

Caching and read replicas come next. Cache rendered fragments and expensive lookups. Route reporting and other read-heavy, lag-tolerant queries to replicas. Writes, and reads that must see them immediately, stay on the primary.

Migrations on large tables

As tables grow, a careless migration can hold a lock that queues every request behind it. Use expand-and-contract: add the new column or table, backfill in small batches, switch reads and writes, then drop the old structure in a later deploy. Build indexes with non-blocking options where your database offers them, such as CREATE INDEX CONCURRENTLY in PostgreSQL. Set a short lock timeout so a blocked migration fails fast instead of stalling traffic.

The trade-offs

Scaling is coarse-grained. If one hot endpoint needs more capacity, you replicate the whole application. The blast radius is shared: a memory leak, a runaway query, or a bad migration affects every module at once. The primary database becomes both the throughput ceiling and the point where modules stay coupled, however clean their code boundaries are.

When to avoid it, when to extract a service, and the alternatives

Those limits are where the case for extraction starts, but the signal needs to be specific. Extract a service when one module has a sharply different resource profile, such as GPU-bound inference or bursty image processing that would otherwise force you to scale every web node. Extract when it needs a different language or runtime. Extract when security or compliance demands hard isolation, for example keeping cardholder data out of the main process and its credentials entirely. Extract when two teams spend more time negotiating deploy windows than shipping. "The code feels big" is not on that list.

The monolith also has failure modes of its own. Boundaries erode quietly unless tooling rejects cross-module calls. Build and test times grow with the codebase, not with the change. Deploy contention rises with headcount. Every module inherits the framework's upgrade cadence, so one stale dependency can block a major-version upgrade for everyone.

Approach Operational cost Boundary strength Typical failure
Unstructured monolith Low None Coupling makes every change risky
Modular monolith + 1–2 satellite services Moderate Enforced in-process, hard at satellites Satellites multiply without a clear rule
Full microservices High Hard, but at network cost Distributed transactions, tracing gaps, platform overhead

Clean seams make extraction possible, not cheap. You still have to split shared tables, replace in-process calls with network calls that can fail or time out, add retries and idempotency, and run both paths during migration. Budget for that work explicitly.

Impact on development cost and engineering time

Count the recurring upkeep each deployable generates, not just the infrastructure bill. Every pipeline, dashboard, alert set, on-call rotation, and internal API contract needs someone to maintain it every month.

Illustrative estimate (assumed numbers, not a benchmark). Monthly overhead = Σ (item count × upkeep hours per item per month). The hours per item are assumptions. Replace them with your own.

Item (assumed hours/item/month) Single deployable Five services
CI/CD pipelines (2 h) 1 → 2 h 5 → 10 h
Dashboards (1 h) 1 → 1 h 5 → 5 h
Alert sets (2 h) 1 → 2 h 5 → 10 h
On-call rotations (4 h) 1 → 4 h 2 → 8 h
Internal API contracts (3 h) 0 → 0 h 6 → 18 h
Monolith-specific work: build caching, test sharding, boundary checks, amortized framework upgrades 10 h 0 h
Total 19 h 51 h

With these inputs, the single deployable saves about 32 hours a month. The table shows only direct upkeep, so the real gap is probably larger. Three costs are left out:

  • Coordination. A contract change in a monolith is one reviewed pull request. Across services it means versioning, a deployment order, and a deprecation window.
  • Onboarding. A new engineer clones one repository and runs one process. They don't need to start five services locally.
  • Debugging. A stack trace crosses module boundaries in one process, so you don't have to correlate traces across network hops.

The monolith's costs are real and arrive in bursts. A slow CI suite slows every team at once, so test parallelism has to be funded early. Boundary tooling needs an owner. A framework upgrade has to land everywhere together, which can take one team out of feature work for a sprint.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to majestic monolith, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.