Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

Spring Modulith

Tung Bui Thanh (TungDaDev)9 mins read

Spring modulith.webp

Đừng vội chia nhỏ hệ thống thành Microservices chỉ vì thấy người ta làm vậy. Một Modular Monolith có cấu trúc module chắc chắn vẫn tốt hơn một Distributed Monolith rời rạc và lộn xộn.

# nỗi đau muôn thuở: "big ball of mud" và cám dỗ Microservices

Dự án Spring Boot sống lâu một chút là gặp kịch bản quen thuộc này:

  1. Ban đầu là Monolith gọn gàng, team push tính năng nhanh.
  2. Code phình dần. Các package bắt đầu import chéo nhau tùy tiện (OrderService gọi InventoryRepository, PaymentService query thẳng bảng Customer,...).
  3. Ranh giới domain bị xóa nhòa. Sửa nhỏ ở module khuyến mãi mà làm sập luôn luồng thanh toán. Hệ thống thành một "Big Ball of Mud" (cục bùn khổng lồ).

Nhìn đống lộn xộn đó, phản xạ của nhiều dev là: "Đập Monolith ra làm Microservices thôi!". Nhưng thực tế là: nếu không giữ nổi ranh giới sạch sẽ trong cùng một repository và một process, thì tách thành network service chỉ tạo ra một Distributed Monolith còn tệ hơn, lại còn kéo theo chi phí vận hành lớn (k8s, network latency, distributed transaction, observability,...).

Vì vậy Modular Monolith mới được nhắc nhiều trong mấy năm gần đây, và Spring Modulith là dự án chính thức của Spring team để làm việc này cho đàng hoàng.


# Spring Modulith là gì?

Spring Modulith (trước đây là dự án Moduliths của Oliver Drotbohm) là một extension chính thức cho Spring Boot (bản 1.x cho Boot 3.x, bản 2.x cho Boot 4.x), cung cấp:

  • Xác định ranh giới module (Module Boundaries) dựa trên quy ước cấu trúc package.
  • Kiểm tra tính đóng gói (Encapsulation Verification): chặn việc module này import trái phép class nội bộ của module khác (dùng ArchUnit bên dưới).
  • Giao tiếp bất đồng bộ qua Domain Events: có sẵn Event Publication Registry ghi lại event cùng transaction nghiệp vụ, để listener lỗi giữa chừng thì event vẫn còn đó mà xử lý lại.
  • Tự sinh tài liệu kiến trúc (Living Documentation): xuất sơ đồ PlantUML (kiểu C4 Component hoặc UML) và Application Module Canvas (bảng mô tả từng module) từ chính source code.

# cấu trúc package trong Spring Modulith

Spring Modulith quy ước: mỗi package con nằm ngay dưới root application package là một Application Module.

com.tungdadev.ecommerce
├── EcommerceApplication.java
│
├── order                     <-- Module "order"
│   ├── Order.java            (Public API của module)
│   ├── OrderService.java     (Public API của module)
│   ├── OrderPlacedEvent.java (Public Domain Event)
│   └── internal              <-- Package nội bộ (Mô-đun khác KHÔNG ĐƯỢC PHÉP gọi)
│       ├── OrderRepository.java
│       └── OrderCalculator.java
│
├── inventory                 <-- Module "inventory"
│   ├── InventoryService.java
│   └── internal
│       └── StockRepository.java
│
└── payment                   <-- Module "payment"
    ├── PaymentService.java
    └── internal
        └── PaymentGatewayClient.java

Mặc định, class nằm ở root của module (com.tungdadev.ecommerce.order) là Public API. Class nằm trong package con (internal hay sub-package nào khác) là Private Implementation, module khác không được truy cập. Muốn mở một sub-package cho module khác dùng (ví dụ order.api) thì đánh dấu nó bằng @NamedInterface.

Lưu ý "không được truy cập" ở đây là do test kiến trúc bên dưới kiểm tra, không phải compiler chặn. Java không có khái niệm "nội bộ của module" trải qua nhiều package: OrderRepository nằm ở order.internal thì phải public để OrderService ở package order dùng được, mà đã public thì module khác cũng import được, code vi phạm vẫn biên dịch bình thường.


# kiểm tra vi phạm kiến trúc bằng unit test

Chỗ mình thấy đáng giá nhất ở Spring Modulith là tự động fail build CI/CD ngay khi có dev lỡ import class nội bộ của module khác.

# thêm dependency

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.modulith</groupId>
            <artifactId>spring-modulith-bom</artifactId>
            <!-- 2.1.x cho Spring Boot 4.x; dự án còn ở Boot 3.5 thì dùng 1.4.x -->
            <version>2.1.1</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-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Dùng BOM để mọi artifact spring-modulith-* cùng một version, khỏi lệch nhau khi thêm starter về sau.

# viết architecture verification test

Test chỉ cần vài dòng:

package com.tungdadev.ecommerce;
 
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;
 
class ApplicationModularityTests {
 
    ApplicationModules modules = ApplicationModules.of(EcommerceApplication.class);
 
    @Test
    void verifyModularity() {
        // Tự động kiểm tra ranh giới, chu trình phụ thuộc (cyclic dependencies)
        modules.verify();
    }
 
    @Test
    void generateDocumentation() {
        // Xuất sơ đồ PlantUML và Module Canvas vào target/spring-modulith-docs
        new Documenter(modules)
            .writeDocumentation()
            .writeModulesAsPlantUml();
    }
}

Giả sử có người trong team viết:

// Trong PaymentService (thuộc module payment):
import com.tungdadev.ecommerce.order.internal.OrderRepository; // <-- VI PHẠM!

Khi chạy mvn test ở máy hoặc trên CI:

org.springframework.modulith.core.Violations:
Module 'payment' depends on non-exposed type com.tungdadev.ecommerce.order.internal.OrderRepository within module 'order'!

Build fail ngay. Kiến trúc nhờ vậy khó bị xuống cấp dần theo thời gian.


# tách rời khớp nối bằng in-process domain events

Thay vì để OrderService inject trực tiếp PaymentService và InventoryService rồi gọi đồng bộ (tight coupling), ta cho các module nói chuyện với nhau qua event:

# phát sinh sự kiện miền trong order module

package com.tungdadev.ecommerce.order;
 
import com.tungdadev.ecommerce.order.internal.OrderRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
public class OrderService {
 
    private final OrderRepository orders;
    private final ApplicationEventPublisher events;
 
    public OrderService(OrderRepository orders, ApplicationEventPublisher events) {
        this.orders = orders;
        this.events = events;
    }
 
    @Transactional
    public void placeOrder(CreateOrderRequest req) {
        // 1. Logic nghiệp vụ tạo đơn hàng, lưu để có id
        Order order = orders.save(new Order(req.customerId(), req.totalAmount()));
 
        // 2. Publish Domain Event
        events.publishEvent(new OrderPlacedEvent(order.getId(), order.getTotalAmount()));
    }
}

# lắng nghe sự kiện ở module khác với @applicationmodulelistener

Spring Modulith cung cấp annotation @ApplicationModuleListener (kết hợp của @Async, @Transactional(propagation = REQUIRES_NEW), và @TransactionalEventListener(phase = AFTER_COMMIT)):

package com.tungdadev.ecommerce.payment;
 
import com.tungdadev.ecommerce.order.OrderPlacedEvent;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
 
@Component
public class PaymentEventListener {
 
    @ApplicationModuleListener
    public void on(OrderPlacedEvent event) {
        // Xử lý tạo bill thanh toán bất đồng bộ
        // Chỉ chạy sau khi transaction của Order đã COMMIT thành công!
        System.out.println("Processing payment for order: " + event.orderId());
    }
}

# giải bài toán transactional event publication registry

Câu hỏi quen thuộc của Event-Driven trong Monolith: Order đã commit vào database, nhưng listener xử lý thất bại (crash, restart container, mất mạng), vậy message có bị mất không?

Spring Modulith có sẵn Event Publication Registry chạy theo cơ chế Outbox:

  1. Khi event được publish, Spring Modulith ghi vào bảng event_publication một bản ghi cho mỗi listener sẽ nhận event đó, trong cùng transaction với dữ liệu nghiệp vụ.
  2. Event listener xử lý xong thành công thì record được đánh dấu hoàn thành (completion_date).
  3. Listener ném exception hoặc container crash giữa chừng thì record nằm lại ở trạng thái chưa hoàn thành. Lưu ý: mặc định Spring Modulith không tự retry. Muốn gửi lại lúc khởi động thì bật spring.modulith.events.republish-outstanding-events-on-restart=true; muốn retry định kỳ khi app đang chạy thì tự viết một job gọi IncompleteEventPublications.resubmitIncompletePublicationsOlderThan(...).

Muốn bật với JDBC, thêm dependency (dùng JPA thì đổi thành spring-modulith-starter-jpa):

<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>

Vài điều nên biết trước khi lên production (chi tiết ở tài liệu chính thức):

  • Bảng event_publication không tự tạo. Để Spring Modulith tạo giúp thì bật spring.modulith.events.jdbc.schema-initialization.enabled=true, còn dự án dùng Flyway/Liquibase thì tự thêm script.
  • Cơ chế này là at-least-once: một event có thể tới listener nhiều lần (retry sau khi listener đã chạy xong nhưng chưa kịp đánh dấu hoàn thành). Listener phải idempotent.
  • Mặc định bản ghi đã hoàn thành vẫn nằm lại trong bảng (spring.modulith.events.completion-mode=update), lâu ngày bảng phình to. Đổi sang delete hoặc archive, hoặc dọn định kỳ bằng CompletedEventPublications.deletePublicationsOlderThan(...).

# tổng kết: đường đi từ Monolith tới Microservices

Tiêu chíBig Monolith truyền thốngModular Monolith (Spring Modulith)Microservices
Ranh giới moduleMơ hồ, phụ thuộc chéoRõ ràng, kiểm duyệt tự động qua testRất nghiêm ngặt (Network boundary)
Độ trễ (Latency)Cực thấp (In-memory calls)Cực thấp (In-memory calls)Cao (Network calls, Serializing JSON/gRPC)
Chi phí vận hànhThấpThấp (1 CI/CD, 1 Deployment unit)Rất cao (K8s, Istio, Tracing, Consul,...)
Tính toàn vẹn dữ liệuACID TransactionACID + Event Publication RegistryBASE, Eventual Consistency, Saga
Khả năng tách MicroserviceCực kỳ khó khănDễ hơn nhiều (ranh giới đã có sẵn)Đã là Microservices

Lời khuyên của mình: cứ bắt đầu với Modular Monolith. Với Spring Modulith, bạn vẫn có tốc độ phát triển và sự đơn giản của Monolith, mà ranh giới giữa các module thì rõ ràng như Microservices. Sau này hệ thống lớn, có module cần scale riêng thì tách nó thành service nhẹ nhàng hơn nhiều so với gỡ một cục monolith rối: ranh giới đã có, giao tiếp đã qua event. Phần khó còn lại là tách dữ liệu và thay transaction cục bộ bằng giao tiếp qua mạng, chỗ này thì vẫn phải làm thật.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.