Software Design & Architecture15 min read·By Liyabona Saki·

Modular Monolith Architecture in Spring Boot — The Right Way to Scale a Monolith

Why modern teams are returning to modular monoliths — module boundaries, package-by-feature, internal events and a clean migration path to microservices in Spring Boot.

Advertisement

Introduction

For most of the 2010s the default answer to *"how should we structure this backend?"* was microservices. A decade later, teams from Shopify, Amazon Prime Video and GitHub have publicly walked some of that back. The pendulum is swinging toward a middle ground: the modular monolith.

A modular monolith gives you the operational simplicity of one deployable, with the internal discipline of a microservices estate. Done right, it scales to hundreds of engineers — and leaves the door open to extract real microservices later, *if* you ever actually need them.

Key takeaways

  • A modular monolith is one deployable, many independent modules with enforced boundaries.
  • Most teams reach for microservices too early — the modular monolith captures 80% of the benefit at 10% of the cost.
  • Module boundaries should mirror business capabilities, not technical layers.
  • Modules communicate through published interfaces and events, never by reaching into each other's tables.
  • Extraction to a microservice becomes a mechanical refactor when the boundaries were honest from day one.

What is a monolith (really)?

A monolith is any system deployed as a single unit. That definition says nothing about quality. There are excellent monoliths and terrible microservices. The problem people usually mean when they say "monolith" is the big ball of mud — a codebase where every package can import every other package, business rules leak across layers, and a change to one feature breaks three others.

Problems with traditional monoliths

  • No enforced boundaries — every class is one import away from every other class.
  • Shared database — every feature reads and writes the same tables, so schema changes are terrifying.
  • All-or-nothing deploys — a bug in the reporting module blocks a hotfix in checkout.
  • Team contention — 40 engineers merging into the same package is a daily merge war.

Microservices were the industry's overcorrection. They solved the boundary problem by putting a network between every module — which is a very expensive way to enforce a rule a compiler could enforce for free.

What is a modular monolith?

A modular monolith is a single deployable composed of independently designed modules, each with:

  • Its own package (or Gradle/Maven submodule).
  • A published API — the only classes other modules may call.
  • Its own persistence — its own tables, ideally its own schema.
  • Its own domain model — no shared "core" entities.

Modules communicate two ways: synchronous calls through the published API, or asynchronous domain events on an in-process bus.

When NOT to use microservices

Skip microservices when any of these are true:

  • Your team is smaller than ~20 engineers.
  • You don't have a dedicated platform/SRE team.
  • Your traffic fits comfortably on one beefy server.
  • You don't yet know which parts of the domain change at different rates.

If you can't answer *"which service owns this data?"* without looking it up, you're not ready for microservices. Start with a modular monolith and earn the right to split.

The reference architecture

We'll build an e-commerce backend with four modules inside one Spring Boot app:

text
┌────────────────────── Spring Boot app ──────────────────────┐
│                                                             │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐  │
│  │  users   │   │ orders   │   │ payments │   │inventory │  │
│  │  module  │   │  module  │   │  module  │   │  module  │  │
│  └────┬─────┘   └────┬─────┘   └────┬─────┘   └────┬─────┘  │
│       │              │              │              │        │
│       └──────────────┴── event bus ─┴──────────────┘        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
                            │
                  one Postgres database
                  (one schema per module)

Step 1 — Multi-module Maven layout

text
ecom-monolith/
  pom.xml                    ← parent
  bootstrap/                 ← @SpringBootApplication lives here
  modules/
    users/
      users-api/             ← public DTOs + interfaces
      users-impl/            ← internal, depends on users-api
    orders/
      orders-api/
      orders-impl/
    payments/
      payments-api/
      payments-impl/
    inventory/
      inventory-api/
      inventory-impl/
  shared-kernel/             ← Money, Email, Page<T> — nothing domain-specific

The rule: impl modules may depend on any number of api modules, but never on another impl. Maven enforces it. The compiler is your architecture cop.

Step 2 — Package-by-feature inside a module

Inside orders-impl, organize by feature, not by layer:

text
com.acme.orders
  ├── PlaceOrder.java           ← use case (application service)
  ├── CancelOrder.java
  ├── Order.java                ← aggregate root
  ├── OrderRepository.java      ← port (interface)
  ├── OrderJpaAdapter.java      ← adapter
  └── OrderRestController.java

This beats the classic controller/ / service/ / repository/ split because everything related to *placing an order* sits next to each other. New engineers find code faster, and unrelated changes don't collide.

Step 3 — The published API

orders-api exposes only what other modules need:

```java
// orders-api
public interface OrderQueries {
  Optional<OrderSummary> findById(UUID id);
  List<OrderSummary> findByCustomer(UUID customerId);
}

public record OrderSummary(UUID id, UUID customerId, Money total, String status) {} ```

orders-impl provides the bean:

java
// orders-impl — package-private, only the @Bean factory is public
@Service
class DefaultOrderQueries implements OrderQueries {
  private final OrderRepository repo;
  // ...
  public Optional<OrderSummary> findById(UUID id) {
    return repo.findById(id).map(OrderSummary::from);
  }
}

Other modules inject OrderQueries. They cannot see Order, OrderRepository, or any JPA entity. The boundary is real.

Step 4 — Events between modules

Synchronous calls couple modules. Prefer domain events for anything that isn't a query:

```java
// orders-api
public record OrderPlaced(UUID orderId, UUID customerId, Money total, Instant occurredAt) {}

// orders-impl @Service class PlaceOrder { private final ApplicationEventPublisher events;

@Transactional public OrderId handle(PlaceOrderCommand cmd) { Order order = Order.place(cmd); repo.save(order); events.publishEvent(new OrderPlaced(order.id(), order.customer(), order.total(), now())); return order.id(); } }

// payments-impl listens @Component class ChargeOnOrderPlaced { @ApplicationModuleListener void on(OrderPlaced event) { paymentService.charge(event.customerId(), event.total(), event.orderId()); } } ```

Spring Modulith ships @ApplicationModuleListener which is *async*, *transactional*, and *persisted* — three properties you want for cross-module events.

Step 5 — Database considerations

The cheapest enforcement is one schema per module in the same Postgres database:

sql
CREATE SCHEMA orders;
CREATE SCHEMA payments;
CREATE SCHEMA inventory;
CREATE SCHEMA users;

Each module's JPA entities live in its own schema. Cross-schema foreign keys are forbidden — that's the database-level equivalent of importing another module's internals. If the orders module needs a customer name, it asks UserQueries.findById(); it does not JOIN users.customer.

When you later extract a module, the schema becomes its own database. No data migration. No surprise foreign keys.

Common mistakes

  • A "shared" module that grows into everything — keep shared-kernel strictly to types with no business meaning (Money, Email, Page).
  • Direct JPA entity sharing — the moment one module's @Entity is imported by another, the boundary is gone.
  • Cross-module transactions — if your @Transactional spans two modules' repositories, you've recreated the big ball of mud.
  • Premature extraction — extracting a module to a microservice before you've felt actual pain is the same mistake as starting with microservices.

Production best practices

  • Use ArchUnit in CI to assert "no class in orders.* may import payments..*".
  • Adopt Spring Modulith — it formalizes modules, verifies boundaries, and generates documentation.
  • One @Configuration class per module — explicit, not classpath-scanned across modules.
  • Treat each module's API as a public contract. Version it. Break it as carefully as you would a REST endpoint.
  • Run module-level integration tests so a module can be tested without booting the others.

Migration strategy from a legacy monolith

1. Map the bounded contexts — event-storming sessions surface the real seams. 2. Carve a single module first — usually the one with the clearest owner and the least coupling. 3. Introduce a published API — replace direct calls with the new interface in one PR per caller. 4. Move the tables to a dedicated schema — keep them in the same database to avoid distributed transactions. 5. Switch synchronous calls to events where the caller doesn't need an answer. 6. Repeat for the next module.

You can ship this work continuously. There is no big-bang rewrite.

When (and how) to extract a microservice

Extract only when you have a concrete operational reason:

  • The module needs to scale on a different axis (e.g. search indexing).
  • It has different uptime requirements.
  • It is owned by a separate team that wants to deploy independently.

When that day comes, extraction is mechanical because:

  • The module already exposes a clean API → it becomes a REST/gRPC service.
  • It already owns its schema → it becomes its own database.
  • Other modules already talk to it through events → those events go to Kafka instead of the in-process bus.

Real-world examples

  • Shopify runs the world's largest Rails monolith — modularized, with strict component boundaries enforced by their internal tooling.
  • Amazon Prime Video publicly moved an over-microserviced pipeline back to a monolith and cut infrastructure cost ~90%.
  • GitHub still serves the bulk of github.com from a single Rails app.

The lesson isn't *"monoliths are always right"*. It's *"the right unit of deployment is whatever lets your team move fastest with the smallest blast radius"* — and for most teams, most of the time, that's a modular monolith.

FAQ

Is a modular monolith just "a monolith with folders"? No. The discipline is in enforced boundaries — module APIs, separate schemas, ArchUnit rules. Without enforcement, it decays back to a ball of mud within months.

Can I use a modular monolith with a microservices frontend (BFF)? Yes — many teams do exactly this. One modular monolith backend serves several BFFs.

Do I need Spring Modulith? No, but it removes a lot of boilerplate. Plain Spring Boot + Maven submodules + ArchUnit gets you 90% of the way there.

Related tutorials

Architecture

Modular Monolith

CLIENTMONOLITHINTERNAL BUSDATABASEpublishsubscribeClientUsers Moduleusers.apiOrders Moduleorders.apiBilling Modulebilling.apiIn-Process EventsApplicationEventPublisherusers schemaorders schemabilling schema
One deployable contains several enforced modules. Modules expose a public API and communicate via in-process events, sharing a single database with isolated schemas.

TL;DR

Key takeaways

  • Understand the core concepts behind Modular Monolith Architecture in Spring Boot — The Right Way to Scale a Monolith in a production context.
  • Apply the patterns to real Software Design & Architecture systems, not just toy examples.
  • Recognize the trade-offs, failure modes, and operational concerns before adopting them.
  • Get a clear path to the next step — related tutorials, tools, and reference architectures.

Avoid these

Common mistakes

  • 1. Copy-pasting code without understanding the trade-offs

    It's tempting to ship a snippet from a blog post into production, but Software Design & Architecture patterns only work when the failure modes are understood. Always reason about timeouts, retries, and consistency.

  • 2. Skipping observability from day one

    Structured logs, metrics, and traces are not optional. Wire them in before you ship — debugging Software Design & Architecture systems without them is painful and expensive.

  • 3. Optimizing too early

    Premature caching, sharding, or microservice extraction adds operational cost. Validate the bottleneck with real measurements first.

  • 4. Ignoring security defaults

    Secrets in env files, open management ports, missing RBAC — these are the most common production incidents. Treat security as part of the definition of done.

Ship it safely

Production best practices

Apply these before promoting Modular Monolith Architecture in Spring Boot — The Right Way to Scale a Monolith to a real production environment.

Scalability

Design Software Design & Architecture services to scale horizontally. Keep request handlers stateless, push session and cache state to external stores (Redis, the database), and benchmark p95/p99 latency under realistic load before tuning.

Monitoring & Observability

Emit metrics (RED/USE), structured JSON logs, and distributed traces from day one. Wire dashboards and alerts to SLOs you actually care about — error rate, latency, saturation — not vanity metrics.

Logging

Log with correlation IDs, never log secrets or PII, and centralize logs (ELK, Loki, CloudWatch). Use levels deliberately: INFO for state changes, WARN for recoverable issues, ERROR for incidents.

Security

Apply least-privilege IAM, rotate secrets through a vault, validate every input, and patch dependencies on a schedule. For HTTP services, enable TLS everywhere and set sensible security headers.

Testing

Layer unit, integration, and contract tests. Run them in CI on every PR, and add smoke tests post-deploy. For Software Design & Architecture systems, also run chaos and load tests before a major release.

Reliability & Rollouts

Ship with health checks, readiness probes, graceful shutdown, and a rollback strategy. Prefer canary or blue/green deploys over big-bang releases.

Questions

Frequently asked questions

Is this tutorial up to date?

Yes. This tutorial was last reviewed and updated on May 24, 2026. We revisit popular Software Design & Architecture tutorials regularly to keep them aligned with current best practices.

What level is this tutorial aimed at?

It is written for working developers with some backend experience. Beginners can still follow along, and senior engineers will find production-grade patterns and trade-off discussions.

Do I need to follow every step in order?

The walkthrough is sequential because each step depends on the previous one. If you only need a specific concept, the table of contents at the top of the article lets you jump straight to that section.

Where can I find the source code?

Code samples are inlined in the tutorial. When a companion repository is published it will be linked at the top of this page.

Go deeper

Further reading

#Spring Boot#Modular Monolith#Architecture#DDD#Microservices

More From the Channel

Follow the full tutorial series on YouTube

The MasterLabSystems channel publishes in-depth, project-based tutorials on Java, Spring Boot, microservices, Docker, Kubernetes, AWS and DevOps — the same topics covered on this site, with full code walkthroughs.

Stay in the Loop

Get the next tutorial in your inbox

next tutorial →

Hexagonal Architecture with Spring Boot — Build Clean, Maintainable Applications

Related tutorials