Cristhian Villegas
Architecture12 min read0 views

From Microservices to Modular Monoliths: Architecture Trends 2026

The Microservices Backlash

For the past decade, microservices were the default architecture for any new project. "If you are not doing microservices, you are doing legacy" was the implicit mantra of the industry. But in 2025-2026, critical voices have multiplied and a clear pattern has emerged: many companies are consolidating their microservices into modular monoliths.

The most notable case was Amazon Prime Video, which in 2023 published how they migrated their video quality monitoring service from a serverless microservices architecture to a monolith, reducing costs by 90%. Segment, Shopify, and Istio followed similar paths.

The problem is not that microservices are bad — it is that most teams adopted distributed complexity without actually needing the independent scalability that justifies that complexity.

Key stat: According to an InfoQ 2025 survey, 43% of companies that adopted microservices report that operational complexity exceeded expected benefits. 31% are evaluating service consolidation.

What is a Modular Monolith?

A modular monolith is an application that deploys as a single unit but is internally organized into modules with clear, encapsulated boundaries. Each module owns its business domain, has its own internal API, and can have its own persistence layer.

The key difference from a traditional monolith (big ball of mud) is the discipline around boundaries:

  • Modules do not directly access other modules' tables
  • Inter-module communication happens through defined public interfaces
  • Each module can evolve internally without affecting others
  • Modules can be extracted into independent services if scale justifies it

Team planning software architecture on a whiteboard

Rule of thumb: If your team has fewer than 50 developers and your product does not require scaling components independently, a modular monolith is probably the right architecture.

Module Boundaries and Encapsulation

The success of a modular monolith depends entirely on the quality of its module boundaries. A well-defined module follows these principles:

  1. High cohesion — everything that changes together lives together
  2. Low coupling — modules communicate through stable contracts
  3. Encapsulation — internal details are not exposed to other modules
  4. Domain alignment — module boundaries reflect DDD bounded contexts

Let's see how a module is structured in Spring Boot with Spring Modulith:

java
1// Package structure of a modular monolith with Spring Modulith
2//
3// com.example.shop/
4// ├── order/                    ← Order module
5// │   ├── Order.java            ← Aggregate root (package-private)
6// │   ├── OrderItem.java        ← Internal entity (package-private)
7// │   ├── OrderRepository.java  ← Repository (package-private)
8// │   ├── OrderService.java     ← Module's public API
9// │   └── OrderCompleted.java   ← Domain event
10// ├── inventory/                ← Inventory module
11// │   ├── InventoryService.java ← Public API
12// │   ├── Stock.java            ← Internal entity
13// │   └── StockReserved.java    ← Domain event
14// └── payment/                  ← Payment module
15//     ├── PaymentService.java   ← Public API
16//     └── PaymentConfirmed.java ← Domain event
17
18// OrderService.java — Order module's public API
19package com.example.shop.order;
20
21import org.springframework.modulith.events.ApplicationModuleListener;
22import org.springframework.context.ApplicationEventPublisher;
23import org.springframework.stereotype.Service;
24import org.springframework.transaction.annotation.Transactional;
25
26@Service
27@Transactional
28public class OrderService {
29
30    private final OrderRepository orderRepository;
31    private final ApplicationEventPublisher events;
32
33    public OrderService(OrderRepository orderRepository,
34                        ApplicationEventPublisher events) {
35        this.orderRepository = orderRepository;
36        this.events = events;
37    }
38
39    public OrderSummary createOrder(CreateOrderRequest request) {
40        var order = Order.create(request.customerId(), request.items());
41        orderRepository.save(order);
42
43        // Publish domain event — other modules react
44        events.publishEvent(new OrderCompleted(
45            order.getId(),
46            order.getCustomerId(),
47            order.getTotalAmount()
48        ));
49
50        return order.toSummary();
51    }
52
53    // Only exposes a DTO, never the internal entity
54    public OrderSummary findOrder(Long orderId) {
55        return orderRepository.findById(orderId)
56            .map(Order::toSummary)
57            .orElseThrow(() -> new OrderNotFoundException(orderId));
58    }
59}

Spring Modulith: First-Class Modules in Spring

Spring Modulith is Spring's official framework for building modular monoliths. It provides three fundamental capabilities:

  • Structure verification — automated tests that validate modules respect their boundaries
  • Domain events — asynchronous inter-module communication with delivery guarantees
  • Auto-generated documentation — generates dependency diagrams between modules
java
1// ModularityTests.java — Verify module boundaries
2package com.example.shop;
3
4import org.junit.jupiter.api.Test;
5import org.springframework.modulith.core.ApplicationModules;
6import org.springframework.modulith.docs.Documenter;
7
8class ModularityTests {
9
10    ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
11
12    @Test
13    void shouldBeCompliant() {
14        // Fails if a module accesses internal classes of another
15        modules.verify();
16    }
17
18    @Test
19    void shouldGenerateDocumentation() {
20        new Documenter(modules)
21            .writeModulesAsPlantUml()     // UML diagram
22            .writeIndividualModulesAsPlantUml()
23            .writeModuleCanvases();        // Per-module canvas
24    }
25}
26
27// InventoryEventListener.java — React to events from another module
28package com.example.shop.inventory;
29
30import com.example.shop.order.OrderCompleted;
31import org.springframework.modulith.events.ApplicationModuleListener;
32import org.springframework.stereotype.Component;
33
34@Component
35class InventoryEventListener {
36
37    private final StockRepository stockRepository;
38
39    InventoryEventListener(StockRepository stockRepository) {
40        this.stockRepository = stockRepository;
41    }
42
43    @ApplicationModuleListener
44    void onOrderCompleted(OrderCompleted event) {
45        // Automatically reserve stock when an order is completed
46        event.items().forEach(item -> {
47            var stock = stockRepository.findByProductId(item.productId());
48            stock.reserve(item.quantity());
49            stockRepository.save(stock);
50        });
51    }
52}
Caution: Spring Modulith does not prevent boundary violations at compile time — it detects them at test time. It is critical that you run modules.verify() in your CI pipeline so violations do not reach production.

.NET Aspire: Microsoft's Approach

In the .NET ecosystem, Aspire represents Microsoft's answer to distributed complexity. While Aspire is not exclusively for modular monoliths, it provides tools that facilitate both modular and distributed architectures.

Aspire offers:

  • App Model — define your application topology as code
  • Service Discovery — automatic dependency resolution between components
  • Dashboard — integrated observability with OpenTelemetry
  • Health checks — automatic dependency health verification

Software architecture notes and diagrams

Database per Module vs Shared Database

One of the most debated decisions in modular monoliths is the persistence strategy. There are three main approaches:

StrategyAdvantagesDisadvantages
Schema per moduleLogical isolation, single DB instanceRequires discipline to avoid cross-schema JOINs
Database per moduleTotal isolation, migratable to microservicesOperational complexity, no native distributed transactions
Shared tables with ownershipSimple, full ACID transactionsRisk of coupling if ownership is not respected
sql
1-- Recommended approach: schema per module
2-- Each module gets its own schema in the same database
3
4CREATE SCHEMA IF NOT EXISTS orders;
5CREATE SCHEMA IF NOT EXISTS inventory;
6CREATE SCHEMA IF NOT EXISTS payments;
7
8-- Orders module: only this module accesses these tables
9CREATE TABLE orders.orders (
10    id          BIGSERIAL PRIMARY KEY,
11    customer_id BIGINT NOT NULL,
12    status      VARCHAR(20) NOT NULL DEFAULT 'PENDING',
13    total       DECIMAL(12,2) NOT NULL,
14    created_at  TIMESTAMP DEFAULT NOW()
15);
16
17CREATE TABLE orders.order_items (
18    id         BIGSERIAL PRIMARY KEY,
19    order_id   BIGINT REFERENCES orders.orders(id),
20    product_id BIGINT NOT NULL,  -- logical reference, NOT a FK to inventory
21    quantity   INT NOT NULL,
22    unit_price DECIMAL(10,2) NOT NULL
23);
24
25-- Inventory module: isolated in its own schema
26CREATE TABLE inventory.products (
27    id    BIGSERIAL PRIMARY KEY,
28    name  VARCHAR(255) NOT NULL,
29    sku   VARCHAR(50) UNIQUE NOT NULL,
30    stock INT NOT NULL DEFAULT 0
31);
32
33-- DO NOT create foreign keys between different schemas
34-- Consistency is maintained through domain events
Recommendation: The schema per module approach is the best trade-off for most teams. It gives you logical isolation without the operational complexity of multiple databases, and facilitates future migration to microservices if you need it.

Inter-Module Communication

In a modular monolith, modules communicate in two ways:

  • Synchronous calls — a module invokes another module's public API directly (service method call)
  • Domain events — a module publishes an event and others react asynchronously

The general rule is: use synchronous calls for queries (you need the response now) and events for commands (notify that something happened, without waiting for a response).

Spring Modulith makes this especially elegant with its transactional event system:

  • Events are published within the emitting module's transaction
  • They are persisted to an outbox table to guarantee delivery
  • Listeners execute in separate transactions
  • If a listener fails, it is automatically retried
Outbox Pattern: Spring Modulith internally implements the Transactional Outbox pattern. Events are stored in the event_publication table and processed asynchronously, guaranteeing at-least-once delivery without requiring an external message broker.

When Do Microservices Still Make Sense?

The modular monolith is not the universal answer. Microservices remain the best choice when:

  • Real independent scaling — one component needs 100x the resources of another
  • Large autonomous teams — more than 50-100 developers with clear ownership
  • Different technology stacks — one service needs Python/ML while another uses Java
  • Independent deployment requirements — you need to deploy a component 10 times a day without touching the rest
  • Critical fault isolation — the failure of one component must not affect others

The key is that these should be decisions based on real, measured needs, not on what is trending at conferences.

Migration Path: From Microservices to Modular Monolith

If you already have microservices and want to consolidate, the migration is not trivial but follows a predictable pattern:

  1. Identify service groups — find services that always deploy together or have intense inter-communication
  2. Define target modules — map each group to a module in the monolith
  3. Consolidate gradually — migrate one group at a time, maintaining compatibility with services that are still independent
  4. Replace HTTP with direct calls — calls between consolidated services become method invocations
  5. Unify persistence — consolidate databases into schemas within a single instance
  6. Remove infrastructure — retire the service meshes, API gateways, and orchestration tools you no longer need
Warning: Do not attempt to migrate everything at once. The Strangler Fig approach works in both directions: you can progressively "absorb" microservices into the monolith incrementally, keeping both worlds running in parallel during the transition.

Real Case Studies and Lessons Learned

Let's examine what real companies have done:

  • Amazon Prime Video — migrated from Step Functions + Lambda to a monolith, saving 90% in infrastructure costs. The reason: inter-service communication was the bottleneck
  • Segment — consolidated over 100 microservices into a modular monolith. The result: 5x fewer operational incidents and faster development cycles
  • Shopify — never fully abandoned the monolith. Instead of migrating to microservices, they invested in modularizing their Ruby on Rails monolith with well-encapsulated components
  • Istio — Google's service mesh consolidated 8 microservices into a single binary (Istiod), drastically simplifying operations and deployment

The pattern is consistent: distributed complexity has a real cost, and many organizations discover that a well-structured monolith gives them better development velocity, lower operational costs, and sufficient scalability for their actual needs.

The most important lesson is that architecture should be a pragmatic decision based on team and business needs, not a fad. In 2026, the modular monolith has gone from "doing things wrong" to being a legitimate architectural choice and, for many teams, a superior one.

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles