The Road We Were Told Not to Take: Why Monolithic Architecture is Winning Again

For the better part of a decade, the software engineering industry operated under an unshakeable dogma: if you were building a modern, scalable cloud application, you had to use microservices. We were warned against the "dreaded monolith." We were told that monolithic codebases were unmaintainable technical debt waiting to happen, and that true engineering maturity meant splitting your application into dozens—if not hundreds—of independently deployable services running in Kubernetes clusters.

So, we took the advice. We broke up our database models, wrapped everything in gRPC or REST APIs, deployed service meshes, configured distributed tracing, and hired dedicated platform teams just to manage the deployment pipelines. But recently, a quiet revolution has been brewing across the developer ecosystem. Engineers are looking at their AWS bills, their latency graphs, and their mental overhead, and asking a dangerous question: What if the path we were explicitly told not to take was actually the right one all along?

Today, we are seeing a massive resurgence of the modern monolith—often called the "Modular Monolith." Heavyweights like Shopify, Basecamp, and even Prime Video have publicly documented their journeys away from microservices back toward unified codebases. Let's dive deep into why this shift is happening, how modern tooling makes the monolith better than ever, and how you can architect a codebase that gives you the productivity of a monolith without turning into a tangled spaghetti mess.

The Hidden Costs of Microservices

Microservices solved a very real problem: **organizational scaling**. When you have 500 engineers working on a single product, they inevitably step on each other's toes in a single repository. Microservices allowed teams to own isolated domains and deploy independently without waiting for enterprise-wide release cycles.

However, small-to-medium-sized teams adopted microservices not because they had 500 engineers, but because they thought it was "best practice." In doing so, they traded internal code complexity for external operational complexity.

1. Distributed Systems Complexity

In a monolithic application, a method call is instantaneous, atomic, and safe. In a microservices architecture, a simple function call becomes a network request over HTTP or gRPC. Suddenly, developers must account for:

  • Network latency: Cascading API calls add up quickly.
  • Partial failures: What happens if Service A succeeds, Service B succeeds, but Service C times out?
  • Data consistency: Distributed transactions (like the Saga pattern) are notoriously difficult to implement and debug compared to a simple SQL BEGIN...COMMIT block.

2. The Developer Experience (DX) Nightmare

Ask any engineer working in a heavy microservices environment how long it takes to boot the application locally. Usually, the answer involves spinning up a heavy Docker Compose setup consuming 30 gigabytes of RAM, or relying on remote staging environments that are constantly out of sync. Feature development slows to a crawl when testing a single change requires updating three different repos and pushing images to a container registry.

Enter the Modular Monolith

The alternative to a disorganized "big ball of mud" isn't 50 microservices; it's a **Modular Monolith**. A modular monolith is a single application binary or process, but internally, its code is strictly partitioned into distinct, isolated domains.

In a modular monolith, communication between domains happens via strict in-memory APIs rather than network calls. You retain compile-time type safety, zero-latency execution, and single-database transactional integrity, while preserving domain boundaries so you can split services apart *if and when* you actually need to.

Let's look at how you can enforce these boundaries in a modern backend system.

Designing Boundaries in Code

Consider an e-commerce platform with three core domains: Users, Orders, and Inventory. In a naive monolith, developers directly import models and run queries across domain tables, creating tight coupling:

// BAD: Tight coupling across domains inside an Order service
import { UserModel } from '../users/user.model';
import { InventoryModel } from '../inventory/inventory.model';

export class OrderService {
  async createOrder(userId: string, productId: string) {
    const user = await UserModel.findById(userId); // Direct DB query to User domain
    const item = await InventoryModel.findById(productId); // Direct DB query to Inventory domain
    
    if (item.stock < 1) throw new Error("Out of stock");
    // Process order...
  }
}

In a Modular Monolith, domains are strictly isolated. The Orders module can only talk to the Inventory module via a clearly defined public interface or an internal event bus. Direct database access across domain boundaries is prohibited.

// GOOD: Domain isolation with explicit public interfaces

// inventory/public-api.ts
export interface IInventoryModule {
  reserveStock(productId: string, quantity: number): Promise<boolean>;
}

// orders/order.service.ts
import { IInventoryModule } from '../inventory/public-api';

export class OrderService {
  constructor(private inventoryModule: IInventoryModule) {}

  async createOrder(userId: string, productId: string) {
    // Communication happens through a contract interface
    const reserved = await this.inventoryModule.reserveStock(productId, 1);
    if (!reserved) throw new Error("Out of stock");
    
    // Process order safely within transaction boundary
  }
}

Notice how this structure enforces the same mental model as microservices. However, the call to reserveStock() is an in-memory method invocation executing in standard application memory—it runs in microseconds, requires no serialization, cannot fail due to network drops, and can be wrapped in a single database transaction.

Enforcing Architecture with Tooling

The main reason teams drifted away from monoliths in the past was that discipline is hard. Without strict architectural guards, deadline pressure eventually causes developers to shortcut public APIs and write direct cross-domain dependencies.

Fortunately, modern developer tooling allows us to enforce modular boundaries at build or test time automatically.

1. Language-Level Workspaces and Package Managers

Languages like Rust, Go, TypeScript, and Java have built-in workspace support. By defining each domain as its own internal package within a monorepo, you can explicitly configure visibility controls. For instance, in a TypeScript/Node environment using Nx or pnpm workspaces, you can define import boundary rules in ESLint:

// .eslintrc.json enforcing domain boundaries
{
  "rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "allow": [],
        "depConstraints": [
          {
            "sourceTag": "domain:orders",
            "onlyDependOnLibsWithTags": ["domain:shared", "domain:inventory-api"]
          },
          {
            "sourceTag": "domain:inventory",
            "onlyDependOnLibsWithTags": ["domain:shared"]
          }
        ]
      }
    ]
  }
}

If a developer tries to import internal code from inventory/src/internal/db.ts inside the orders module, the linter or compiler fails the build immediately.

2. Architecture Verification Tests

In languages like C# or Java, libraries like NetArchTest or ArchUnit let you write unit tests that check your architectural compliance:

// Example ArchUnit test in Java/Kotlin
@Test
public void ordersDomainShouldNotAccessUsersInternal() {
    ArchRule rule = noClasses()
        .that().resideInAPackage("..com.sysseder.orders..")
        .should().accessClassesThat()
        .resideInAPackage("..com.sysseder.users.internal..");

    rule.check(importedClasses);
}

When Should You Microservice?

Choosing a modular monolith isn't an anti-cloud or anti-scale stance—it's a pragmatic recognition of operational trade-offs. However, microservices still make sense under specific circumstances:

  • Vastly Different Scaling Requirements: If one specific module requires thousands of GPU instances (e.g., video encoding or machine learning inference) while the rest of your CRUD app runs fine on two standard virtual machines.
  • Independent Organizational Velocity: When you have independent engineering groups operating in different timezones with distinct release lifecycles.
  • Polyglot Requirements: When a specific domain fundamentally requires a language ecosystem other than your main stack (e.g., Python for data analytics embedded in a Go web app).

If you *do* reach the scale where a specific module needs to become its own service, the modular monolith makes this transition trivial. Because you've already enforced interface boundaries and data isolation, extracting a single module out into an independent HTTP or gRPC service is a refactoring task, not a complete rewrite.

Conclusion

The road we were told not to take—the monolith—turns out to be the fastest, most resilient path for 95% of applications being built today. By embracing the **Modular Monolith**, engineering teams regain instant local dev setups, effortless deployment pipelines, low operational overhead, and simplified database transactions, all while keeping their codebases structured and clean.

Before you spin up another Kubernetes cluster or design an event-driven system across a dozen repos, step back and ask: Could this just be a module inside a solid, modern monolithic core?


What's your take? Has your team transitioned back to a monolithic design, or are microservices still serving you well? Let me know in the comments below or reach out on Twitter/X at @sysseder!

Post a Comment

Previous Post Next Post