Javathoughts Logo
Javathoughts Blogs
Subscribe to the newsletter
Published on
Views

Why Modular Monolith is the Smart Alternative to Microservices (Lessons from Shopify)

Authors
  • avatar
    Name
    Javed Shaikh
    Twitter
The chaos of premature microservices adoption — tangled services, deployment errors, and exhausted engineers

🔥 The Night Everything Broke

It was 2 AM on a Thursday. Priya's phone wouldn't stop buzzing.

She was the lead backend engineer at a mid-sized fintech startup — the kind of company that had 12 microservices, 6 developers, and a Kubernetes cluster that consumed more AWS budget than the entire engineering team's salaries combined.

The order service was down. But here's the fun part — nobody could figure out why.

The payment service was healthy. The user service was responding. The notification service was... well, nobody really knew what the notification service was doing at any given moment. It had become the team's unsupervised teenager — technically part of the family, but doing its own thing.

After four hours of tracing distributed logs across Jaeger, digging through Kafka consumer lag dashboards, and restarting things in a specific order that felt more like a rain dance than engineering — they found it.

A single config change in the inventory service had cascading failures across three other services that nobody knew were tightly coupled through shared Kafka topics.

Sound familiar?

If you've spent any meaningful time with microservices, you've lived some version of this story. And you've probably asked yourself the question that Shopify — one of the largest e-commerce platforms on the planet — asked years ago:

"Do we actually need microservices? Or are we just making our lives unnecessarily complicated?"


💀 The Problem with Microservices (That Nobody Warned You About)

Let's be real. Microservices are not inherently bad. They solve real problems — at a certain scale, with certain team structures, when domain boundaries are well-understood.

But here's what the conference talks and Medium articles don't tell you:

🚢 Deployment Complexity That Eats Your Weekends

With 15 microservices, you don't have one CI/CD pipeline. You have fifteen. Each with its own versioning, its own Docker images, its own Helm charts, and its own mysterious reason for failing at 4 PM on Friday.

You need a service mesh. You need a container orchestrator. You need distributed tracing. You need centralized logging. You need a PhD in YAML.

🐛 Debugging Nightmares Across Network Boundaries

In a monolith, a bug is a stack trace. In microservices, a bug is a mystery novel. The symptoms show up in Service A, the root cause lives in Service C, and Service B was the accomplice that corrupted the data in transit.

Good luck explaining that in a postmortem.

💸 Distributed Transactions — The Distributed Headache

Need to place an order that involves deducting inventory, charging a payment, and sending a confirmation? In a monolith, that's a database transaction. In microservices, that's the Saga pattern — a choreography of compensating transactions that makes your brain hurt and your QA team cry.

🏗️ Over-Engineering on Day One

Here's the brutal truth: most startups and mid-sized companies don't have Netflix's traffic, Uber's scale, or Amazon's team size. Yet they architect their systems as if they do. This is what Martin Fowler famously called the "Microservice Premium" — the upfront tax you pay in complexity before you get any benefit.

You're not Netflix. You probably don't need 47 services to serve 500 concurrent users.


🧱 What is a Modular Monolith? (Explained Simply)

A modular monolith — one deployment, multiple well-organized internal modules with clear boundaries

Forget the textbook definition. Here's the analogy:

Imagine a well-organized house. It has separate rooms — a kitchen, a bedroom, a bathroom, a living room. Each room has a clear purpose. You don't cook in the bathroom (hopefully). You don't sleep in the kitchen (unless it's been that kind of week).

But here's the key: it's still one house. One address. One roof. One front door. One utility bill.

A Modular Monolith is exactly that:

  • Single deployable unit — one application, one deployment pipeline, one artifact
  • Internally modular — organized into distinct modules by business domain
  • Strong boundaries — modules communicate through well-defined interfaces, not random method calls
  • Shared database, but isolated schemas — each module owns its tables, no cross-module direct DB access

It gives you the organizational benefits of microservices (separation of concerns, team autonomy, domain isolation) without the operational overhead (network calls, distributed transactions, deployment hell).

Think of it as microservices... without the network.


🗺️ Visual Breakdown: How Modules Work Together

Let's look at a typical e-commerce modular monolith:

┌──────────────────────────────────────────────────────┐
SINGLE APPLICATION│                                                       │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  │
│  │   📦 Order   │  │  💳 Payment │  │  👤 User    │  │
│  │   Module     │  │   Module    │  │   Module    │  │
│  │             │  │             │  │             │  │
│  │ OrderService │  │PaymentSvc   │  │ UserService │  │
│  │ OrderRepo    │  │PaymentRepo  │  │ UserRepo    │  │
│  │ order_*      │  │payment_*    │  │ user_*      │  │
│  │ tables       │  │tables       │  │ tables      │  │
│  └──────┬───────┘  └──────┬──────┘  └──────┬──────┘  │
│         │    InterfaceInterface    │        │
│         └────────API───────┴────────API──────┘        │
│                                                       │
│  ┌─────────────┐  ┌─────────────┐                    │
│  │ 📋 Inventory│  │ 📧 Notif.  │  │   Module     │  │   Module    │                    │
│  └─────────────┘  └─────────────┘                    │
│                                                       │
│  ═══════════════════════════════════════════════════  │
ONE deployment → ONE artifact                │
└──────────────────────────────────────────────────────┘

Key rules:

  • The Order Module never directly queries payment_transactions table — it calls PaymentService.processPayment()
  • The User Module exposes a UserApi interface; other modules depend on the interface, not the implementation
  • Each module has its own domain model, repository, and service layer
  • Communication happens through in-process method calls via interfaces — no HTTP, no serialization, no network latency

🧬 Deep Dive: The Principles That Make It Work

1. Domain-Driven Design (DDD) — The Foundation

A modular monolith without DDD is just... a messy monolith with folders. DDD gives you the intellectual framework to identify bounded contexts — areas of the business that have their own models, their own language, and their own rules.

In an e-commerce app:

  • The Order context cares about line items, totals, and order status
  • The Payment context cares about transactions, refunds, and payment methods
  • The Inventory context cares about stock levels, warehouses, and reservations

These are separate worlds that happen to coexist in the same application.

2. Bounded Contexts — The Invisible Walls

Each module is a bounded context. The Order module has its own definition of Product (just an ID and a price). The Inventory module has a completely different definition of Product (with stock levels, warehouse locations, supplier info).

They don't share domain models. They share identifiers. This is what prevents the spaghetti.

3. Enforced Boundaries — No "Just This Once" Hacks

The most critical principle: boundaries must be enforced, not just documented. Documentation gets ignored. CI checks don't.

In Java, you can enforce this using:

  • Package-private visibility — classes that shouldn't be accessed outside the module are package-private
  • ArchUnit tests — automated architecture tests that fail the build if someone violates module boundaries
  • Java Platform Module System (JPMS)module-info.java for true compile-time enforcement
// ArchUnit test to enforce module boundaries
@ArchTest
static final ArchRule order_module_should_not_access_payment_internals =
    noClasses()
        .that().resideInAPackage("..order..")
        .should().accessClassesThat()
        .resideInAPackage("..payment.internal..");

This test fails your build if someone in the Order module tries to sneak into Payment's internal packages. No "just this one time" exceptions.


🟢 The Shopify Case Study: How a $100B Company Chose the Monolith

Shopify's modular monolith architecture — organized modules inside a single codebase, scaled with Pods

This is the part that matters most. Because Shopify isn't a small startup making a contrarian bet — it's one of the largest e-commerce platforms in the world, powering millions of merchants, processing 284 million requests per minute during Black Friday, and handling $7.5 billion in sales over a single weekend.

And they run on a modular monolith built with Ruby on Rails.

Let that sink in.

Why Shopify Didn't Go Microservices

In the early days, Shopify faced the same crossroads every scaling company faces: the codebase was growing, teams were stepping on each other's toes, and the words "microservices" were floating around every architecture meeting.

But Shopify's engineering leadership made a deliberate decision: don't split the monolith. Organize it.

Their reasoning was profoundly practical:

  • Microservices would multiply operational complexity — and they didn't have the team size to absorb it
  • Domain boundaries weren't clear enough — splitting prematurely would mean splitting along the wrong lines
  • Developer velocity was more important than architectural purity — shipping features fast matters more than having a beautiful service mesh diagram

How They Organized the Monolith

Shopify introduced componentization — dividing their massive Rails codebase into well-defined components organized by business domain: Checkout, Payments, Inventory, Shipping, Admin, and more.

Each component operates like a module with strict rules:

  • No reaching into another component's internals — you use its public API
  • No shared database models — each component owns its data
  • Explicit dependency declarations — every cross-component dependency is visible and tracked

Packwerk: The Boundary Enforcer

To prevent their 3-million-line Rails codebase from devolving into spaghetti, Shopify built Packwerk — an open-source static analysis tool that enforces boundaries between components at CI time.

Packwerk works like ArchUnit for Ruby:

  • It defines packages (components) with explicit boundaries
  • It detects privacy violations (accessing internal classes of another package)
  • It detects dependency violations (depending on a package you haven't declared)
  • It runs in CI and fails the build if architectural rules are violated

This is the key insight: modularity isn't a guideline at Shopify — it's enforced by tooling.

Scaling with Pods (Not Kubernetes Pods)

Here's where Shopify gets really clever. While the code is a monolith, the runtime infrastructure is horizontally sharded using Pods — their own concept, distinct from Kubernetes pods.

Each Shopify Pod is an isolated instance of the entire platform that manages a specific subset of shops:

  • Dedicated MySQL clusters per pod
  • Dedicated Redis and Memcached instances per pod
  • Blast radius isolation — if Pod 7 has a database problem, shops in Pod 12 are completely unaffected
  • Shop-level routing — incoming requests are routed to the correct pod based on shop_id

This gives Shopify the horizontal scalability typically associated with microservices — without actually having microservices. Same codebase, same deployment, but isolated data planes.

When Shopify Does Use Microservices

Shopify isn't religiously anti-microservices. They've selectively extracted services where it genuinely makes sense:

  • Storefront Renderer — extracted for independent scaling (storefronts have wildly different traffic patterns than admin panels)
  • Payments infrastructure — separated for compliance and regulatory reasons
  • Specialized ML services — unique runtime requirements (Python, GPU workloads)

The philosophy is surgical: extract when there's a proven need, not a speculated one.

"We don't decompose to microservices because it's trendy. We extract a service when the monolith can no longer serve that specific use case well." — Shopify Engineering


⚔️ Modular Monolith vs. Microservices: The Real-World View

The real tradeoff — microservices complexity vs modular monolith simplicity

Forget generic comparison tables. Let's look at real scenarios:

🔧 Scenario 1: "We need to deploy the payment module independently"

Microservices answer: Great, it's already a separate service. Deploy it.

Modular Monolith answer: Do you really need independent deployment? Or do you just need independent development? If the latter, modular monolith already gives you that. If you truly need independent deployment (regulatory compliance, different scaling profile), extract just that module into a service. You've already got clean interfaces — the extraction is straightforward.

🐞 Scenario 2: "Customer sees wrong order total on checkout"

Microservices debugging: Check the API gateway logs → trace the request through the order service → discover it called the pricing service → which called the discount service → which timed out calling the inventory service → which was healthy but had a stale cache from the product service. Total debugging time: 3 hours across 5 services.

Modular Monolith debugging: Set a breakpoint in OrderService.calculateTotal(). Step through. Find the bug. Fix it. Total debugging time: 20 minutes. One JVM. One stack trace.

📈 Scenario 3: "We need to scale for Black Friday"

Microservices answer: Scale each service independently based on its traffic profile. Set up auto-scaling groups. Configure service mesh. Manage circuit breakers. Pray.

Modular Monolith answer (Shopify style): Spin up more application instances behind a load balancer. If you need data isolation, use the Pod pattern — shard by tenant/shop/region. One codebase, multiple isolated instances.

🤝 Scenario 4: "We have 200 engineers and need team autonomy"

Microservices answer: Give each team their own service(s). Accept the coordination tax.

Modular Monolith answer: Give each team their own module(s) with enforced boundaries. They develop independently within the same repo. Conflicts are caught at compile time, not at 2 AM in production.


☕ Implementing a Modular Monolith in Java (Spring Boot)

Spring Boot Modular Monolith — organized package structure with clear module boundaries

Here's how to structure a modular monolith in a real Spring Boot application:

Project Structure

com.javathoughts.ecommerce/
├── order/
│   ├── api/                    # Public interfaces — other modules use these
│   │   ├── OrderApi.java       # Interface for cross-module communication
│   │   └── OrderDto.java       # DTOs exposed to other modules
│   ├── internal/               # Package-private — nobody outside can access
│   │   ├── OrderService.java
│   │   ├── OrderRepository.java
│   │   └── Order.java          # Domain entity (internal)
│   └── web/
│       └── OrderController.java
├── payment/
│   ├── api/
│   │   ├── PaymentApi.java
│   │   └── PaymentDto.java
│   ├── internal/
│   │   ├── PaymentService.java
│   │   ├── PaymentRepository.java
│   │   └── Payment.java
│   └── web/
│       └── PaymentController.java
├── user/
│   ├── api/
│   │   └── UserApi.java
│   ├── internal/
│   │   ├── UserService.java
│   │   └── UserRepository.java
│   └── web/
│       └── UserController.java
└── shared/
    ├── event/                  # Domain events for async communication
    │   └── DomainEvent.java
    └── exception/
        └── BusinessException.java

Module API Interface

// order/api/OrderApi.java — The public contract for the Order module
public interface OrderApi {
    OrderDto createOrder(CreateOrderRequest request);
    OrderDto getOrder(Long orderId);
    List<OrderDto> getOrdersByUser(Long userId);
}

Internal Service Implementation

// order/internal/OrderService.java
@Service
class OrderService implements OrderApi {  // Note: package-private class

    private final OrderRepository orderRepository;
    private final PaymentApi paymentApi;      // Depends on Payment's API, not internals
    private final InventoryApi inventoryApi;  // Depends on Inventory's API
    private final ApplicationEventPublisher eventPublisher;

    @Override
    @Transactional
    public OrderDto createOrder(CreateOrderRequest request) {
        // 1. Verify inventory
        inventoryApi.reserveStock(request.getItems());

        // 2. Create the order
        Order order = Order.create(request);
        orderRepository.save(order);

        // 3. Process payment
        paymentApi.processPayment(order.getId(), order.getTotal());

        // 4. Publish domain event (async notification to other modules)
        eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));

        return OrderMapper.toDto(order);
    }
}

Enforcing Boundaries with ArchUnit

@AnalyzeClasses(packages = "com.javathoughts.ecommerce")
public class ModuleBoundaryTests {

    @ArchTest
    static final ArchRule modules_should_only_depend_on_api_packages =
        slices().matching("..ecommerce.(*)..")
            .should().notDependOnEachOther()
            .ignoreDependency(
                resideInAPackage("..api.."),
                alwaysTrue()
            );

    @ArchTest
    static final ArchRule internal_packages_should_not_be_accessed =
        noClasses()
            .that().resideOutsideOfPackage("..order.internal..")
            .should().accessClassesThat()
            .resideInAPackage("..order.internal..");

    @ArchTest
    static final ArchRule no_cross_module_repository_access =
        noClasses()
            .that().resideInAPackage("..order..")
            .should().dependOnClassesThat()
            .resideInAPackage("..payment.internal..");
}

Best Practices Checklist

  • Modules communicate only through api interfaces — never call internal classes directly
  • Each module owns its database tables — no cross-module JOINs, no shared entities
  • Use Spring Events for async communicationOrderCreatedEvent triggers notification without direct coupling
  • Domain entities are package-privateOrder.java in internal is invisible to other modules
  • ArchUnit tests in CI — boundary violations break the build, not production

🚦 When to Actually Move to Microservices

A modular monolith isn't a forever architecture. It's a starting point that earns you the right to extract services when you have evidence, not assumptions.

Here are the clear signals that it's time to extract a module into a microservice:

  • 🔴 Independent scaling is non-negotiable — one module needs 50 instances while others need 3
  • 🔴 Different technology requirements — a module genuinely needs Python/ML instead of Java
  • 🔴 Regulatory isolation — payment processing requires PCI-compliant, separately audited infrastructure
  • 🔴 Team size exceeds 100+ engineers — coordination costs in a monorepo start outweighing the benefits
  • 🔴 Deployment frequency diverges wildly — one team deploys 10x/day, another deploys weekly
  • 🔴 You've proven the domain boundary — the module has been stable for months and hasn't needed schema changes that affect other modules

The beauty of a well-structured modular monolith? Extraction is straightforward. Your module already has a clean api interface, its own data, and no internal dependencies from other modules. Turning PaymentApi.java (an in-process interface) into PaymentClient.java (a REST/gRPC client) is a mechanical refactor, not an architectural redesign.

Start modular. Evolve when needed. Extract with evidence.


The Shopify story teaches us something profound: architecture decisions should be driven by your actual constraints — team size, domain maturity, scale requirements — not by what's trending on Hacker News.

Shopify processes billions of dollars in transactions, handles hundreds of millions of requests per minute, and powers millions of merchants worldwide. They could afford microservices. They chose not to — because the modular monolith gave them everything they needed without the operational tax they didn't.

Next time someone in your architecture meeting says "We should use microservices," ask them three questions:

  1. How many engineers do we have? (If < 50, you probably don't need microservices)
  2. Are our domain boundaries well-understood? (If no, you'll split along the wrong lines)
  3. Have we tried organizing the monolith first? (If no, start there)

The smartest architecture isn't the most complex one. It's the one that lets your team ship fast, debug easily, and scale when — and only when — the evidence demands it.

Build the house first. Add rooms as you grow. Don't start by building a hotel when you only need three bedrooms.


If this resonated with you, share it with a teammate who's drowning in microservices YAML files. They'll thank you. 🙏