Spring Modulith
Tung Bui Thanh (TungDaDev)9 mins read

Đừ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:
- Ban đầu là Monolith gọn gàng, team push tính năng nhanh.
- Code phình dần. Các package bắt đầu import chéo nhau tùy tiện (
OrderServicegọiInventoryRepository,PaymentServicequery thẳng bảngCustomer,...). - 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.javaMặ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:
- Khi event được publish, Spring Modulith ghi vào bảng
event_publicationmột bản ghi cho mỗi listener sẽ nhận event đó, trong cùng transaction với dữ liệu nghiệp vụ. - Event listener xử lý xong thành công thì record được đánh dấu hoàn thành (
completion_date). - 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ọiIncompleteEventPublications.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_publicationkhông tự tạo. Để Spring Modulith tạo giúp thì bậtspring.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 sangdeletehoặcarchive, hoặc dọn định kỳ bằngCompletedEventPublications.deletePublicationsOlderThan(...).
# tổng kết: đường đi từ Monolith tới Microservices
| Tiêu chí | Big Monolith truyền thống | Modular Monolith (Spring Modulith) | Microservices |
|---|---|---|---|
| Ranh giới module | Mơ hồ, phụ thuộc chéo | Rõ ràng, kiểm duyệt tự động qua test | Rấ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ành | Thấp | Thấp (1 CI/CD, 1 Deployment unit) | Rất cao (K8s, Istio, Tracing, Consul,...) |
| Tính toàn vẹn dữ liệu | ACID Transaction | ACID + Event Publication Registry | BASE, Eventual Consistency, Saga |
| Khả năng tách Microservice | Cực kỳ khó khăn | Dễ 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 😎 👍🏻 🚀 🔥.