DEV Community

Avaneesh Yadav
Avaneesh Yadav

Posted on Originally published at buildingai.in

The Modular Monolith: The Java Architecture Most Teams Should Be Using

In 2018, the engineering blog you were reading told you to decompose everything into microservices. In 2022, the same blog started running post-mortems about distributed system complexity. In 2026, teams that survived both cycles are settling on something that looks a lot like what worked in 2010 — but done properly this time.

The modular monolith.

Not a big ball of mud. Not a distributed system pretending to be one service. A deliberately structured codebase where the modules are as isolated as microservices but everything runs in one process, shares one database transaction when needed, and gets deployed as one artifact.

If your team has fewer than 50 engineers, ships to fewer than 10 teams, and is spending more time debugging cross-service issues than building features — this post is for you. I'll show you the exact package structure, how to enforce module boundaries with ArchUnit, how to do cross-module communication without coupling, and when you'll actually know it's time to extract a service.

[!NOTE]
All examples use Java 21, Spring Boot 3.x, and a domain roughly based on an order management system — three bounded contexts: Orders, Catalog, and Customers. The patterns apply regardless of your domain.

Why Most "Microservices" Are Actually Distributed Monoliths

Before the architecture, a diagnostic. Here's the most common failure mode I encounter:

OrderService calls CustomerService via REST
    → CustomerService calls AccountService via REST
        → AccountService calls AuthService via REST

PlaceOrder() now has 4 network hops, 4 failure modes,
4 deployment dependencies, and distributed transaction risk.
Enter fullscreen mode Exit fullscreen mode

This is not a microservices architecture. This is a monolith where the method calls were replaced with HTTP calls. The coupling is identical — it's just now expressed as API contracts instead of import statements, and every call can fail with a network error.

The teams I've seen thrive with microservices have two things: genuinely independent business capabilities with separate teams owning them end-to-end, and the organizational maturity to run distributed systems (platform team, service mesh, distributed tracing, on-call rotations per service). That's a real investment. For most teams, it's not the right trade.

The modular monolith gives you the architectural discipline of microservices without the operational overhead.

The Core Principle: Module Boundaries Are Enforced, Not Suggested

A modular monolith is not just "we put things in separate packages." Every Java codebase has packages. The difference is enforcement.

In a modular monolith:

  • One module cannot import the internal implementation of another module
  • Cross-module communication happens through defined interfaces, not direct class references
  • Database access is scoped per module (even if you share one database)
  • These rules are tested by the build — not just agreed on in a wiki

Let me show you what this looks like in practice.

Module Structure

src/main/java/com/company/app/
├── orders/
│   ├── api/                    ← Public API of this module
│   │   ├── OrderService.java   ← Interface (not implementation)
│   │   ├── OrderDto.java       ← Data structures safe to share
│   │   └── OrderEvents.java    ← Domain events this module publishes
│   ├── domain/                 ← Private: domain model
│   │   ├── Order.java
│   │   ├── OrderItem.java
│   │   ├── OrderStatus.java
│   │   └── OrderRepository.java
│   ├── application/            ← Private: use cases / application services
│   │   └── OrderServiceImpl.java
│   ├── infrastructure/         ← Private: DB, messaging, external APIs
│   │   ├── JpaOrderRepository.java
│   │   └── OrderMapper.java
│   └── OrdersConfig.java       ← @Configuration for this module's beans
│
├── catalog/
│   ├── api/
│   │   ├── CatalogService.java
│   │   ├── ProductDto.java
│   │   └── CatalogEvents.java
│   ├── domain/
│   ├── application/
│   ├── infrastructure/
│   └── CatalogConfig.java
│
├── customers/
│   ├── api/
│   ├── domain/
│   ├── application/
│   ├── infrastructure/
│   └── CustomersConfig.java
│
└── shared/
    ├── Money.java              ← Value objects used by multiple modules
    ├── CustomerId.java
    └── DomainEvent.java        ← Base type for all domain events
Enter fullscreen mode Exit fullscreen mode

The rule is simple: everything in api/ is public. Everything else is private.

The orders module can call CatalogService (which is in catalog/api/). It cannot import catalog/domain/Product.java or catalog/infrastructure/JdbcCatalogRepository.java.

Enforcing Boundaries with ArchUnit

The structure above is meaningless without enforcement. Engineers will break it under deadline pressure. The compiler won't stop them — Java doesn't enforce package-level visibility like a module system does (and JPMS is a separate conversation). ArchUnit does.

@AnalyzeClasses(packages = "com.company.app", importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {

    private static final List<String> MODULES = List.of("orders", "catalog", "customers");

    @ArchTest
    static final ArchRule no_module_accesses_internals_of_another =
        noClasses()
            .that().resideInAPackage("com.company.app.(*)..")
            .should().accessClassesThat()
            .resideInAPackage("com.company.app.(*).domain..")
            .orShould().accessClassesThat()
            .resideInAPackage("com.company.app.(*).application..")
            .orShould().accessClassesThat()
            .resideInAPackage("com.company.app.(*).infrastructure..")
            // unless you're inside that same module
            .andShould(beInTheSameModule());

    @ArchTest
    static final ArchRule infrastructure_does_not_depend_on_other_modules_infrastructure =
        noClasses()
            .that().resideInAPackage("com.company.app.(*).infrastructure..")
            .should().accessClassesThat()
            .resideInAPackage("com.company.app.(*).infrastructure..")
            .andShould(beInDifferentModules());

    @ArchTest
    static final ArchRule domain_has_no_spring_annotations =
        noClasses()
            .that().resideInAPackage("com.company.app.(*).domain..")
            .should().beAnnotatedWith(Component.class)
            .orShould().beAnnotatedWith(Service.class)
            .orShould().beAnnotatedWith(Repository.class);
}
Enter fullscreen mode Exit fullscreen mode

The last rule — no Spring annotations in domain classes — enforces that your domain model is a pure Java object model, not a Spring-managed bean graph. This makes your domain independently testable without starting a Spring context.

This test suite runs in CI. A PR that breaks module boundaries doesn't merge. That's the whole mechanism.

Defining the Module API

Each module exposes its capabilities through a Java interface in api/:

// orders/api/OrderService.java
public interface OrderService {

    OrderDto placeOrder(PlaceOrderCommand command);
    OrderDto getOrder(UUID orderId);
    List<OrderDto> getOrdersForCustomer(UUID customerId);
    void cancelOrder(UUID orderId);
}
Enter fullscreen mode Exit fullscreen mode

And DTO types — not domain objects:

// orders/api/OrderDto.java
public record OrderDto(
    UUID id,
    UUID customerId,
    List<OrderLineDto> lines,
    Money totalAmount,
    String status,
    Instant createdAt
) {}

public record OrderLineDto(UUID productId, String productName, int quantity, Money unitPrice) {}
Enter fullscreen mode Exit fullscreen mode

Why DTOs instead of domain objects? Domain objects carry business logic and invariants — they're not safe to expose outside the module. Exposing Order directly creates an implicit coupling: any change to Order (adding a field, changing a type, splitting a class) breaks every caller. DTOs are a stable contract. Changes to domain model don't ripple out.

Cross-Module Communication via Domain Events

The most common question: "Orders needs to know when a product price changes in Catalog. How does that work without Orders depending on Catalog internals?"

Domain events. Catalog publishes an event. Orders subscribes.

// catalog/api/CatalogEvents.java
public sealed interface CatalogEvent extends DomainEvent permits
    CatalogEvent.ProductPriceChanged,
    CatalogEvent.ProductDiscontinued,
    CatalogEvent.ProductRestocked {

    record ProductPriceChanged(
        UUID productId,
        Money oldPrice,
        Money newPrice,
        Instant occurredAt
    ) implements CatalogEvent {}

    record ProductDiscontinued(
        UUID productId,
        String reason,
        Instant occurredAt
    ) implements CatalogEvent {}

    record ProductRestocked(
        UUID productId,
        int newQuantity,
        Instant occurredAt
    ) implements CatalogEvent {}
}
Enter fullscreen mode Exit fullscreen mode

Using Java 21 sealed interfaces here is intentional. The permits clause makes the event hierarchy exhaustive — when you add a new event type, every switch that handles CatalogEvent gets a compile error if it doesn't handle the new case. Your IDE finds every subscriber that needs updating.

Publishing an event:

// catalog/application/CatalogServiceImpl.java
@Service
@Transactional
class CatalogServiceImpl implements CatalogService {

    private final ProductRepository productRepository;
    private final ApplicationEventPublisher eventPublisher;

    public void updatePrice(UUID productId, Money newPrice) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));

        Money oldPrice = product.getPrice();
        product.setPrice(newPrice);
        productRepository.save(product);

        // Published AFTER the transaction commits via @TransactionalEventListener
        eventPublisher.publishEvent(
            new CatalogEvent.ProductPriceChanged(productId, oldPrice, newPrice, Instant.now())
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Subscribing from Orders:

// orders/application/OrderPriceReconciler.java
@Component
class OrderPriceReconciler {

    private final OrderRepository orderRepository;

    @TransactionalEventListener(phase = AFTER_COMMIT)
    void onPriceChanged(CatalogEvent.ProductPriceChanged event) {
        // Find open orders containing this product
        List<Order> openOrders = orderRepository
            .findOpenOrdersContaining(event.productId());

        openOrders.forEach(order -> {
            order.updateLinePrice(event.productId(), event.newPrice());
            log.info("Updated price for product {} in order {} from {} to {}",
                event.productId(), order.getId(), event.oldPrice(), event.newPrice());
        });
    }

    @TransactionalEventListener(phase = AFTER_COMMIT)
    void onProductDiscontinued(CatalogEvent.ProductDiscontinued event) {
        // Cancel any pending orders containing a discontinued product
        orderRepository.findPendingOrdersContaining(event.productId())
            .forEach(order -> order.cancel("Product discontinued: " + event.reason()));
    }
}
Enter fullscreen mode Exit fullscreen mode

The critical detail: @TransactionalEventListener(phase = AFTER_COMMIT).

Without this, the event fires during the catalog transaction, before the price update is committed. If the Orders handler reads the product from the database, it sees the old price. AFTER_COMMIT ensures the event fires only after the catalog transaction commits successfully — if catalog's transaction rolls back, Orders never hears about the price change.

This is the event-driven communication model that prevents half-committed states across modules.

Database Isolation Within One Schema

Sharing one database doesn't mean sharing tables. Each module owns its tables, and that ownership is enforced through a naming convention and an ArchUnit rule.

-- Orders module tables
CREATE TABLE ord_orders (id UUID PRIMARY KEY, customer_id UUID NOT NULL, ...);
CREATE TABLE ord_order_items (id UUID PRIMARY KEY, order_id UUID NOT NULL, ...);

-- Catalog module tables  
CREATE TABLE cat_products (id UUID PRIMARY KEY, name VARCHAR NOT NULL, ...);
CREATE TABLE cat_categories (id UUID PRIMARY KEY, ...);

-- Customers module tables
CREATE TABLE cus_customers (id UUID PRIMARY KEY, email VARCHAR NOT NULL, ...);
CREATE TABLE cus_addresses (id UUID PRIMARY KEY, customer_id UUID NOT NULL, ...);
Enter fullscreen mode Exit fullscreen mode

The prefix (ord_, cat_, cus_) makes ownership visible. More importantly:

@ArchTest
static final ArchRule orders_module_only_accesses_ord_tables =
    noClasses()
        .that().resideInAPackage("com.company.app.orders..")
        .should().accessField(JdbcTemplate.class, "queryForList")
        // more practically: check @Table(name=...) annotations
        // for JPA entities in orders only use names starting with "ord_"
Enter fullscreen mode Exit fullscreen mode

What about joins? This is the question that causes the most pushback. The answer is: you have two options, and both are valid.

Option A — Application-level join. Call CatalogService.getProducts(productIds) from the Orders module and merge in Java. This works well when the data sets are small and the query is selective.

Option B — Shared read model. For reporting queries that need cross-module joins, create a dedicated read model (a reports/ package or a separate schema) that denormalizes data explicitly for that purpose. This is the CQRS pattern applied at the module level.

What you do not do: write a SQL query that JOINs ord_orders and cat_products from inside the Orders module. That is a module boundary violation expressed in SQL instead of Java imports. ArchUnit won't catch it, but it's the same coupling.

Spring Configuration Per Module

Each module has its own @Configuration class that defines the beans for that module:

// orders/OrdersConfig.java
@Configuration
@ComponentScan(basePackages = "com.company.app.orders")
@EnableJpaRepositories(
    basePackages     = "com.company.app.orders.infrastructure",
    entityManagerFactoryRef = "ordersEntityManagerFactory",
    transactionManagerRef   = "ordersTransactionManager"
)
public class OrdersConfig {

    @Bean
    @Primary
    public LocalContainerEntityManagerFactoryBean ordersEntityManagerFactory(
        DataSource dataSource, JpaProperties jpaProperties) {
        var factory = new LocalContainerEntityManagerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPackagesToScan("com.company.app.orders.domain");
        factory.setJpaProperties(jpaProperties.getProperties());
        return factory;
    }
}
Enter fullscreen mode Exit fullscreen mode

Separate EntityManagerFactory per module means JPA entity scanning is scoped. The Orders module's JPA context only knows about Orders domain classes. Catalog domain classes are invisible to it. This prevents accidental lazy-loading of entities across module boundaries and gives you the option to split the module to a separate database later — the JPA configuration change is isolated to one module's config file.

Testing Strategy

The module structure directly shapes your testing strategy. Each module is tested in three layers:

1. Domain tests — no Spring, no database (fast):

class OrderTest {

    @Test
    void order_with_no_items_cannot_be_placed() {
        Order order = Order.create(UUID.randomUUID());
        assertThatThrownBy(order::place)
            .isInstanceOf(InvalidOrderException.class)
            .hasMessageContaining("at least one item");
    }

    @Test
    void cancelled_order_cannot_be_placed() {
        Order order = Order.createWithItems(List.of(anItem()));
        order.cancel("test");
        assertThatThrownBy(order::place)
            .isInstanceOf(InvalidOrderException.class);
    }
}
Enter fullscreen mode Exit fullscreen mode

These tests start in milliseconds. Run them 10,000 times in the time it takes a Spring context to load.

2. Application service tests — mocked dependencies (medium speed):

@ExtendWith(MockitoExtension.class)
class OrderServiceImplTest {

    @Mock CatalogService catalogService;
    @Mock OrderRepository orderRepository;
    @Mock ApplicationEventPublisher eventPublisher;
    @InjectMocks OrderServiceImpl orderService;

    @Test
    void placeOrder_verifies_product_exists_in_catalog() {
        when(catalogService.getProduct(PRODUCT_ID))
            .thenReturn(Optional.empty());

        assertThatThrownBy(() -> orderService.placeOrder(aPlaceOrderCommand()))
            .isInstanceOf(ProductNotFoundException.class);

        verify(orderRepository, never()).save(any());
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Module integration tests — real database, no other modules:

@SpringBootTest(classes = OrdersConfig.class)
@Testcontainers
class OrdersModuleIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @MockBean CatalogService catalogService;  // mock the api, not the impl
    @MockBean CustomerService customerService;

    @Autowired OrderService orderService;

    @Test
    @Sql("/orders/test-data.sql")
    void getOrdersForCustomer_returns_only_their_orders() {
        List<OrderDto> orders = orderService.getOrdersForCustomer(CUSTOMER_A_ID);
        assertThat(orders).allMatch(o -> o.customerId().equals(CUSTOMER_A_ID));
    }
}
Enter fullscreen mode Exit fullscreen mode

Note @SpringBootTest(classes = OrdersConfig.class) — this starts only the Orders module's Spring context. No Catalog beans, no Customer beans. The other modules' @Service interfaces are mocked. This makes the test ~5× faster than a full context load and keeps the test scope honest.

4. Cross-module tests — full context, event integration (slow, few):

@SpringBootTest
@Testcontainers
class OrderCatalogIntegrationTest {

    @Autowired CatalogService catalogService;
    @Autowired OrderRepository orderRepository;

    @Test
    void price_change_updates_pending_orders() {
        // Arrange: create a pending order with a product
        UUID productId = catalogService.createProduct(aProductRequest()).id();
        UUID orderId = orderService.placeOrder(PlaceOrderCommand.of(productId, 1)).id();

        // Act: change the product price
        catalogService.updatePrice(productId, Money.of(99, "USD"));
        // give @TransactionalEventListener AFTER_COMMIT time to fire
        awaitility().untilAsserted(() -> {
            OrderDto order = orderService.getOrder(orderId);
            assertThat(order.totalAmount()).isEqualTo(Money.of(99, "USD"));
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Keep the count of these tests small — they're slow and test module integration, not module logic.

Module Communication Anti-Patterns

Three patterns that look fine in code review and cause problems six months later:

1. Sharing domain objects across modules

// ❌ Orders module importing Catalog domain object
import com.company.app.catalog.domain.Product;

class OrderServiceImpl {
    public void placeOrder(PlaceOrderCommand cmd) {
        Product product = productRepository.findById(cmd.productId()); // direct JPA
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

When Catalog adds a field to Product for a new feature, Orders' code and database query break. Use the CatalogService interface and ProductDto instead.

2. Bypassing the API to reach the repository

// ❌ Orders directly autowiring Catalog's JPA repository
@Autowired CatalogProductRepository catalogProductRepo; // private to catalog module

List<Product> products = catalogProductRepo.findByIds(ids); // bypasses business logic
Enter fullscreen mode Exit fullscreen mode

The repository access bypasses any business rule in CatalogServiceImpl. If Catalog adds authorization checks or audit logging to getProducts(), Orders won't get them. ArchUnit catches this in CI.

3. Chatty synchronous communication for every operation

// ❌ Orders calling Catalog for every line item in a loop
order.getItems().forEach(item -> {
    ProductDto product = catalogService.getProduct(item.productId()); // N calls
    // ...
});
Enter fullscreen mode Exit fullscreen mode

Even in a monolith this is N database queries. Use bulk fetching:

// ✅ Batch load
Set<UUID> productIds = order.getItems().stream()
    .map(OrderItem::getProductId).collect(toSet());
Map<UUID, ProductDto> products = catalogService.getProducts(productIds); // 1 query
Enter fullscreen mode Exit fullscreen mode

The Real Answer to "When Do You Extract a Microservice?"

After working with this architecture on several teams, I've converged on two signals that actually predict when extraction is worth it — not the ones usually cited.

Signal 1: Two teams want to deploy independently at different cadences and their releases are colliding.

If the Catalog team ships 3 times a day and their changes keep blocking Orders deployments (or vice versa), the deployment coupling is hurting real people. Extract at the deployment boundary, not the domain boundary.

Signal 2: One module has dramatically different infrastructure needs that are fighting the rest of the application.

Catalog might need a read-replica for search queries and a CDN-friendly caching strategy. Orders needs ACID transactions and message queues. These are genuinely different operational profiles. If forcing them into one deployment means both get suboptimal infrastructure, extraction might be worth it.

Signals that don't actually predict success:

  • "The module is getting big" — 50K lines in one module is fine. Microservices don't reduce code volume.
  • "We want to use different technology" — Mixing JVM languages across module boundaries is asking for integration pain. One Java monolith beats a polyglot distributed system for most teams.
  • "It will be easier to scale" — Unless you've proven that specific module is the bottleneck, horizontal scaling of the whole monolith is usually easier, faster, and cheaper.

When you do extract, the module boundary you built here makes it clean. The api/ package becomes the service's public API contract. The domain and application packages become the service's implementation. Infrastructure switches from local beans to HTTP clients (or message consumers). The module already behaves like a service — you're just moving it to its own process.

The Numbers

We adopted this architecture at a client engagement: a six-year-old Spring Boot monolith, ~150K lines of Java, 11-person team, 4 bounded contexts.

Before the restructure: engineers regularly broke each other's code, because everything was in com.company.app.service.* and import-anything-from-anywhere was the norm. Build time was the only feedback loop.

After a 3-week restructure (largely automated — this is the kind of transformation where Claude Code genuinely helps with the mechanical moves):

  • Cross-module bugs in CI: dropped from 3–5 per week to essentially zero (ArchUnit catches them)
  • Build time: unchanged (structural, not a build change)
  • Onboarding: new engineers understand the module they're working in within a day, instead of needing to understand the whole codebase
  • Time to extract a module to a real microservice when the business needed it: 2 days, not 2 weeks

The two days is the number that justified the investment.

Getting Started

If you're converting an existing codebase, the path is:

  1. Identify 3–5 natural domain areas in your codebase (what nouns does your business talk about? Orders, Customers, Catalog, Payments, etc.)
  2. Create the package structure — this is mechanical, Claude Code does it well
  3. Add the ArchUnit test with your boundaries — it will fail on everything first
  4. Fix violations module by module, starting with infrastructure (no cross-module repository access), then domain (no imported domain objects), then API (replace direct class imports with interface + DTO)
  5. For new features, enforce the structure from day one — don't wait until the refactor is complete

The structure pays off immediately: even with 30% of violations still remaining, you have a clear map of where the coupling exists. That's half the battle.

Most teams don't need microservices. They need modules. The modular monolith gives you the architectural clarity of microservices, the operational simplicity of a single deployment, and a realistic migration path to distributed services if you ever genuinely need them.

Build the right thing first.

The package structure and ArchUnit rules in this post are available as a GitHub template — check the footer for the link.

Avaneesh Yadav is Engineering Manager at HashedIn by Deloitte, building enterprise systems with Java and AI. He writes at buildingai.in.

Top comments (0)