All articles

// Knowledge.log — 技術記事

Bounded Contexts in Practice: Stop Calling Everything a Microservice

Use domain rules, transactions, and data ownership to define boundaries before extracting services from a Spring monolith.

Your system has 14 services, seven repositories need changes to deliver one feature, and every service handles a slightly different version of Order. That does not prove the architecture is distributed by domain. It only proves that git clone has gained operational importance.

The outcome we want is verifiable: before opening another repository, the boundaries must pass a structural test, each invariant must have a single owner, and modules must be able to change without accessing one another's internal details. If that still does not happen inside one process, the network will not fix the design.

The Current Combination for the Example

The baseline used here is Java 25 LTS, Spring Boot 4.1.0, and Spring Modulith 2.1.0. Boot 4.1 supports Java 17 through 26 and manages Spring Framework 7.0.8, according to the official requirements matrix. Java 26 is also supported, but it is not LTS, so the example stays on Java 25.

Spring Boot 3.5.16 with Java 25 remains a valid combination for teams that are migrating, but it is the previous major version. The safe path is to move tests and dependencies to Boot 4.1 first, resolve breaking changes, and only then introduce the modular boundary. Mixing a major-version upgrade, domain reorganization, and process extraction in the same pull request is not worth it.

The minimal pom.xml looks like this:

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>shop</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>25</java.version>
        <spring-modulith.version>2.1.0</spring-modulith.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.modulith</groupId>
                <artifactId>spring-modulith-bom</artifactId>
                <version>${spring-modulith.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.modulith</groupId>
            <artifactId>spring-modulith-starter-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.modulith</groupId>
            <artifactId>spring-modulith-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.modulith</groupId>
            <artifactId>spring-modulith-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

spring-modulith-starter-core provides the structure for modeling and verifying modules, but it does not include the event API. spring-modulith-starter-jdbc adds spring-modulith-events-api—where @ApplicationModuleListener lives—and the Event Publication Registry with JDBC storage. Meanwhile, spring-boot-starter-jdbc and H2 provide the DataSource and transaction manager required to run the example without an external database.

A Domain Boundary Is Not a Team Split

A bounded context defines the boundary of a model and its language. Order can mean purchase intent in the ordering context and a source of items to pick in inventory. Both contexts may share an OrderId; they do not need to share the Order class, its tables, or its rules.

The org chart helps assign responsibility, but it does not demonstrate a boundary. The boundary appears when one side can change without forcing the other to update with it, when each side protects its invariants, and when communication happens through an explicit API or event. A microservice candidate must not cut an aggregate in half: the transaction that preserves the invariant is the lowest possible cut.

Before separating any aggregate, answer the five questions below. If a rule must hold atomically on both sides, if there is a single transaction, or if both sides write to the same tables, keep them together. Move toward a boundary only when language, invariants, data, and the change cycle allow independence.

QuestionSignal to keep togetherSignal to separateRemediation and safe observation
Does the term have the same meaning and rules?One model is coherentEach side uses its own vocabulary and rulesWrite a glossary for each context and translate at the boundary. In production, compare OpenAPI contracts and record mapping failures without logging sensitive data.
Is there a shared invariant?The rule must hold at the same instantEach aggregate protects its own rulePut the rule back in a single aggregate. Observe compensations, saga failures, and incidents that require coordinated correction.
Are the change cycles independent?The same ticket and release span multiple repositoriesEach module changes and is released on its ownUse a modular monolith and one pipeline while a release train still exists. Use PR history to measure how many components change per delivery.
Is a single ACID commit required?The operation cannot tolerate an intermediate stateEventual consistency is acceptableKeep the transaction inside the aggregate and use an event with durable publication between contexts. Monitor pending publications, retries, and dead letters.
Does each side exclusively own its data?Both write to the same tables or depend on cross-context FKsEach module controls its schema and integrates through an event or APISeparate schemas, remove cross-context writes, and create read projections. Audit database access and migration failures by module.

The durable publication mentioned in the table depends on the Event Publication Registry and configured storage, such as spring-modulith-starter-jdbc or spring-modulith-starter-jpa, plus the corresponding transaction manager. spring-modulith-starter-core alone does not record incomplete publications.

The decision is deliberately conservative. When the answer is ambiguous, keep everything in the same deployable and create a logical boundary first. Extracting too early turns a modeling question into a modeling question with a timeout.

Two Modules, One Application

Start with packages by feature under a single @SpringBootApplication class:

// src/main/java/com/example/shop/ShopApplication.java
package com.example.shop;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

The order module publishes only the identifier it needs. The invariant—an order without items cannot be completed—remains inside Order:

// src/main/java/com/example/shop/order/OrderId.java
package com.example.shop.order;

import java.util.UUID;

public record OrderId(UUID value) {}
// src/main/java/com/example/shop/order/OrderCompleted.java
package com.example.shop.order;

public record OrderCompleted(OrderId orderId) {}
// src/main/java/com/example/shop/order/Order.java
package com.example.shop.order;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

public final class Order {
    private final OrderId id = new OrderId(UUID.randomUUID());
    private final List<String> productIds = new ArrayList<>();

    public void addLine(String productId) {
        if (productId == null || productId.isBlank()) {
            throw new IllegalArgumentException("productId is required");
        }
        productIds.add(productId);
    }

    OrderCompleted complete() {
        if (productIds.isEmpty()) {
            throw new IllegalStateException("an order needs at least one line");
        }
        return new OrderCompleted(id);
    }
}
// src/main/java/com/example/shop/order/OrderManagement.java
package com.example.shop.order;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderManagement {
    private final ApplicationEventPublisher events;

    public OrderManagement(ApplicationEventPublisher events) {
        this.events = events;
    }

    @Transactional
    public void complete(Order order) {
        events.publishEvent(order.complete());
    }
}

The transaction on the publishing method is not decorative. According to the Spring Modulith event documentation, @ApplicationModuleListener combines asynchronous execution with a new transaction and a transactional event listener. Because fallback execution is not enabled by default, publishing outside a transaction does not trigger this listener.

inventory knows the public event, not the aggregate or order.internal.*. The listener receives the notification through the integration provided by Modulith:

// src/main/java/com/example/shop/inventory/InventoryManagement.java
package com.example.shop.inventory;

import com.example.shop.order.OrderCompleted;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Service;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class InventoryManagement {
    private final Set<String> completedOrders = ConcurrentHashMap.newKeySet();

    @ApplicationModuleListener
    void on(OrderCompleted event) {
        completedOrders.add(event.orderId().value().toString());
    }
}

In a real system, the listener would load the required data from a projection or explicit contract and update only the inventory schema. It would not receive Order and then go sightseeing through someone else's aggregate.

The Test That Prevents Leakage

Spring Modulith derives modules from the packages directly below com.example.shop. The structural gate belongs in CI:

// src/test/java/com/example/shop/ModularityTests.java
package com.example.shop;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;

class ModularityTests {
    @Test
    void modulesShouldRespectBoundaries() {
        ApplicationModules.of(ShopApplication.class).verify();
    }
}

verify() rejects cycles and access to another module's internal types. For example, if someone creates com.example.shop.order.internal.OrderLinesMustNotBeEmpty and imports that class into inventory, the test fails. The fix is not to make the class public: it is to return the invariant to Order and have inventory react only to the public event.

The aggregate's behavior also deserves a direct test:

// src/test/java/com/example/shop/order/OrderTests.java
package com.example.shop.order;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertThrows;

class OrderTests {
    @Test
    void refusesToCompleteWithoutLines() {
        var order = new Order();

        assertThrows(IllegalStateException.class, order::complete);
    }
}

The first test protects the architecture; the second protects the business rule. One does not replace the other. In production, expose modules through Actuator when that makes sense, track incomplete publications in the Event Publication Registry, and correlate logs by OrderId without serializing the entire aggregate. In CI, keep verify() mandatory and run module tests with @ApplicationModuleTest as each boundary gains its own cases.

When Different Teams Should Still Ship Together

Use the signals already consolidated in the table, not the org chart, to decide deployment boundaries. Different teams can own packages through CODEOWNERS; that does not require inserting a network call in the middle of an invariant.

Keeping deployment together does not mean accepting a package with no boundaries. Small public APIs, protected internals, separate schemas, and explicit events remain mandatory. You can even use the same database server; the problem is sharing tables and write authority, not the IP address.

DevDojo recommends extraction when the signals in the “separate” column predominate and the module passes verify(), and can be deployed, monitored, and rolled back without coordinating with the other context. Until that is true, keep a modular monolith and strengthen the logical boundary.

Incrementally Refactoring the Monolith

Do not rewrite the system. Grow the boundary around existing behavior:

  1. Reorganize one flow at a time into feature packages such as order and inventory, without changing the deployment.
  2. Add the Modulith BOM and starters; put ApplicationModules.verify() in CI.
  3. Move implementations into internal packages and fix each cross-context access with a small API, an event, or by returning the rule to the correct aggregate.
  4. Replace direct calls between modules with events only where eventual consistency is acceptable. Configure spring-modulith-starter-jdbc, spring-modulith-starter-jpa, or equivalent storage for durable publication, and handle retries before removing the old path.
  5. Give each module exclusive authority over its tables or schema and create projections for external reads.
  6. Add @ApplicationModuleTest to critical flows and use change history to see whether the supposed independence actually appeared.
  7. Extract a process using the Strangler pattern only when the module can be deployed, monitored, and rolled back without coordinating order and inventory.

The limitation of this approach is straightforward: Modulith verifies code dependencies; it does not decide whether the language is correct or detect by itself a table shared through SQL, migration, or indirect access. That is why the structural gate must be paired with domain tests, data ownership, and observation of actual changes.

As a next step, choose an aggregate that currently spans several packages, answer the five questions, and add the test with ApplicationModules.of(...).verify(). If the test goes green without making everything public, you have found a useful boundary. If it does not, you have found modeling work—before finding a larger infrastructure bill.

architecturejavaspring-bootmodulith

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles