Vấn đề: Khi Monolith trở thành một ‘Big Ball of Mud’
Chúng ta đều bắt đầu dự án mới với nhiều kỳ vọng và một kiến trúc sạch sẽ. Bạn có thể tổ chức code vào các package tiêu chuẩn như service, repository, và controller. Mọi thứ có vẻ rất ngăn nắp trong ngày đầu tiên. Tuy nhiên, khi mã nguồn đạt đến 50.000 dòng hoặc đội ngũ phát triển vượt quá năm người, những ranh giới đó thường bắt đầu rạn nứt.
Áp lực phải bàn giao tính năng thường dẫn đến các đường tắt trong kiến trúc. Một lập trình viên đang làm phần ‘Billing’ (Thanh toán) có thể nhanh chóng import một repository từ ‘Inventory’ (Kho hàng) để tiết kiệm thời gian. Hệ quả tất yếu là ứng dụng của bạn trở thành một mớ hỗn độn (spaghetti code). Thay đổi một logic nhỏ trong module vận chuyển có thể vô tình làm hỏng cổng thanh toán ở ba tầng khác nhau.
Nhiều đội ngũ cố gắng thoát khỏi mớ hỗn độn này bằng cách chuyển sang Microservices. Đáng tiếc là họ thường đánh đổi sự phức tạp của code lấy những rắc rối về vận hành như độ trễ mạng, distributed tracing (truy vết phân tán) và các vấn đề về nhất quán dữ liệu. Có một giải pháp trung hòa tốt hơn: Bạn cần một Modular Monolith, và Spring Modulith là framework giúp hiện thực hóa điều đó một cách chặt chẽ trong hệ sinh thái Java.
Khái niệm Modular Monolith
Modular Monolith là một đơn vị triển khai duy nhất—một file JAR—nơi mã nguồn bên trong được phân chia nghiêm ngặt. Khác với monolith truyền thống, các module này có những ‘bức tường’ vững chãi. Một module chỉ nên công khai một API cụ thể, giữ kín các lớp hỗ trợ (helper classes) và logic bên trong đối với phần còn lại của hệ thống.
Spring Modulith, hiện là một dự án đã sẵn sàng cho môi trường production thuộc hệ sinh thái Spring, cung cấp cho bạn các công cụ để xác thực những ranh giới này. Nó sử dụng cấu trúc package Java để định nghĩa cái gì là ‘module’ và cái gì là ‘nội bộ’. Quan trọng nhất, nó sẽ làm hỏng các bài test (fail tests) nếu một lập trình viên cố tình vi phạm kiến trúc.
Cơ chế cốt lõi của Spring Modulith
- Ranh giới dựa trên Package: Mỗi sub-package trực tiếp dưới class ứng dụng chính được coi là một module riêng biệt.
- Đóng gói nghiêm ngặt: Code trong package cấp cao nhất của module là công khai (public). Bất cứ thứ gì trong các sub-package (như
.internal) đều không thể được nhìn thấy bởi các module khác. - Xác thực kiến trúc: Nó tự động chạy các bài test dựa trên ArchUnit để phát hiện phụ thuộc vòng hoặc truy cập trái phép giữa các module.
Thực hành: Triển khai Spring Modulith
Hãy cùng xây dựng một hệ thống cửa hàng với hai module: Order (Đơn hàng) và Inventory (Kho hàng). Mục tiêu là cho phép Order gọi InventoryService công khai, trong khi vẫn giữ các database helper trong Inventory ở chế độ riêng tư tuyệt đối.
1. Thiết lập dự án
Thêm Spring Modulith BOM và core starter vào file pom.xml của bạn. Sử dụng BOM đảm bảo tất cả các phụ thuộc của module luôn đồng bộ với nhau.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>1.3.0</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>
2. Định nghĩa cấu trúc Module
Cấu trúc quyết định kiến trúc. Nếu package cơ sở của bạn là com.example.store, hãy sắp xếp các thư mục như sau:
com.example.store.order(Module Order – Công khai)com.example.store.inventory(Module Inventory – Công khai)com.example.store.inventory.internal(Logic nội bộ)
Spring Modulith coi inventory là cổng kết nối. Bất kỳ class nào bên trong inventory.internal đều nằm ngoài giới hạn truy cập của package order.
3. Tạo Module Inventory
Đầu tiên, chúng ta định nghĩa một service công khai. Đây là ‘cửa chính’ của module.
package com.example.store.inventory;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
public boolean isStockAvailable(String productId) {
return true;
}
}
Tiếp theo, chúng ta tạo một component nội bộ. Class này có thể xử lý việc ánh xạ cơ sở dữ liệu thô mà chúng ta không muốn để lộ cho các đội ngũ khác.
package com.example.store.inventory.internal;
import org.springframework.stereotype.Component;
@Component
public class InventoryDataHelper {
public void performSecretTask() {
// Logic không bao giờ được phép gọi từ module Order
}
}
Biến kiến trúc thành một Unit Test
Trong một dự án Java tiêu chuẩn, từ khóa public cho phép truy cập từ bất cứ đâu. Đây là lý do tại sao kiến trúc dần bị xuống cấp. Spring Modulith thay đổi quy tắc bằng cách phân tích bytecode của bạn trong giai đoạn test.
Tạo một class test để đóng vai trò là ‘người gác cổng’ kiến trúc:
package com.example.store;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(StoreApplication.class);
@Test
void verifyModularity() {
// Build sẽ thất bại nếu lập trình viên tạo ra phụ thuộc vòng
// hoặc truy cập vào package .internal từ bên ngoài.
modules.verify();
}
@Test
void writeDocumentation() {
// Tự động tạo biểu đồ PlantUML cho các module của bạn!
new Documenter(modules).writeModulesAsPlantUml();
}
}
Nếu một lập trình viên cố gắng import InventoryDataHelper vào trong OrderService, bài test modules.verify() sẽ thất bại. Nó cung cấp một thông báo lỗi rõ ràng chỉ ra điểm vi phạm. Điều này chuyển việc quản trị kiến trúc từ một tài liệu PDF thành một phần trong pipeline CI/CD của bạn.
Kết luận
Lựa chọn Modular Monolith là một quyết định thực tế cho hầu hết các ứng dụng doanh nghiệp. Nó giúp mã nguồn của bạn luôn ngăn nắp và dễ kiểm thử mà không tốn chi phí vận hành cho 20 microservices khác nhau. Bằng cách sử dụng Spring Modulith, bạn ngăn chặn sự ‘thối rữa’ của code ngay từ khi nó chưa bắt đầu.
Cách tiếp cận này mang lại cho bạn những gì tốt nhất từ cả hai thế giới. Bạn có được sự đơn giản của một đơn vị triển khai duy nhất ở hiện tại, đồng thời có các ranh giới sạch sẽ cần thiết để tách thành Microservices trong tương lai nếu quy mô thực sự yêu cầu.

