multi-module spring boot

- Published on
- /11 mins read/
Khi quy mô một hệ thống phần mềm doanh nghiệp vượt mốc hàng trăm nghìn dòng code và hàng chục kỹ sư cùng commit hàng ngày, kiến trúc Monolith đơn module (single-module) truyền thống sẽ nhanh chóng bộc lộ những điểm gãy chết người: thời gian build kéo dài, ranh giới domain bị xóa nhòa, circular dependencies xuất hiện tràn lan, và một lỗi nhỏ ở module phụ trợ có thể làm sập toàn bộ ứng dụng.
Tuy nhiên, chuyển dịch vội vã sang Microservices khi ranh giới nghiệp vụ chưa ổn định thường dẫn đến thảm họa lớn hơn: Distributed Monolith (Monolith phân tán) với độ trễ mạng, chi phí hạ tầng tăng vọt và sự phức tạp của distributed transactions.
Modular Monolith triển khai dưới dạng Multi-Module Project trong Spring Boot chính là "điểm cân bằng vàng" (sweet spot) về mặt kiến trúc. Nó mang lại tính đóng gói (encapsulation), ranh giới rõ ràng (clear boundaries), kiểm soát luồng phụ thuộc một chiều (unidirectional dependencies), trong khi vẫn giữ được sự đơn giản trong deployment và transaction ACID cục bộ.
Bài viết này đi sâu vào toàn bộ bức tranh kỹ thuật của một kiến trúc Multi-Module Spring Boot chuẩn production: từ phân tầng Clean Architecture, cơ chế quản lý Bean & Connection Pool, cô lập JPA, giải quyết "ca khó" JDBC PostgreSQL, đến việc tự động hóa kiểm tra ranh giới bằng ArchUnit.
# bản đồ phụ thuộc chuẩn clean architecture
Sai lầm phổ biến nhất khi triển khai Multi-Module là chia module theo "cảm tính" hoặc chia theo layer kỹ thuật nông cạn (controller-module, service-module, repository-module). Cách chia này dẫn đến việc thay đổi một tính năng nghiệp vụ đòi hỏi phải sửa code xuyên suốt tất cả các module, phá vỡ nguyên lý Common Closure Principle (CCP).
Kiến trúc chuẩn production kết hợp giữa Domain-Driven Design (DDD) và Clean/Hexagonal Architecture, chia hệ thống thành các module độc lập theo luồng phụ thuộc nghiêm ngặt:
# nguyên tắc cốt lõi của dependency rule
- Module
core-domainlà "trái tim" bất khả xâm phạm: Hoàn toàn không phụ thuộc vào Spring Boot, Hibernate, Jackson hay bất kỳ thư viện bên thứ ba nào (chỉ dùng Java SE thuần túy). Mọi thay đổi về framework không bao giờ được phép làm ảnh hưởng đến domain logic. - Luồng phụ thuộc luôn hướng vào trong: Tầng hạ tầng (Infrastructure/Adapters) phụ thuộc vào Tầng ứng dụng (Application), và Tầng ứng dụng phụ thuộc vào Domain.
- Module
app-bootstrapđóng vai trò là "Composer" duy nhất: Chỉ có module này mới biết đầy đủ các module khác để thực hiện lắp ráp (Assembly) và kích hoạt Spring Boot context.
# cấu hình gradle multi-module & compile avoidance
Để tối ưu hóa thời gian build và ngăn ngừa rò rỉ dependencies ngầm định, cấu hình build tool phải được thiết kế chặt chẽ:
// settings.gradle
rootProject.name = 'enterprise-modular-monolith'
include 'common-kernel'
include 'core-domain'
include 'core-application'
include 'infra-persistence'
include 'infra-messaging'
include 'api-rest'
include 'app-bootstrap'# phân biệt api vs implementation trong compile avoidance
TIP
Quy Tắc Biên Dịch Gradle:
- Dùng
implementation: Khi một dependency chỉ là chi tiết nội bộ của module. Nếu dependency này thay đổi ABI, các module phụ thuộc phía trên không cần phải biên dịch lại (Compile Avoidance giúp tăng tốc độ build lên 3-5 lần). - Dùng
api: Khi types của dependency xuất hiện trực tiếp trong public method signature của module hiện tại.
// core-application/build.gradle
plugins {
id 'java-library'
}
dependencies {
// Xuất khẩu core-domain ra ngoài cho các adapter sử dụng
api project(':core-domain')
implementation project(':common-kernel')
// Chỉ dùng Validation API trừu tượng, không kéo Hibernate Validator cồng kềnh vào core
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
}# quản trị hạ tầng & connection pool
Mỗi hạ tầng kết nối (Relational DB, NoSQL, Cache) đều gắn liền với một Connection Pool vật lý tiêu tốn tài nguyên hệ điều hành (Socket descriptors, native memory threads, OS buffers).
Nếu mỗi module tự cấu hình một DataSource (HikariCP) hoặc một RedisConnectionFactory (Lettuce), một hệ thống gồm 6 module có thể âm thầm mở hàng trăm kết nối nhàn rỗi xuống database, nhanh chóng làm cạn kiệt connection pool của PostgreSQL/MySQL:
Total Connections = Tổng số (MaxPoolSize * AppInstances) của các moduleNếu có 6 module, mỗi module đặt MaxPoolSize = 20, và scale 5 instance container:
Total Connections = 6 * 20 * 5 = 600 connectionsCon số này vượt xa mức tải mặc định (100 connections) của PostgreSQL, gây sập server ngay lập tức.
# giải pháp: single source of truth & shared factory
Tập trung hóa việc khởi tạo ConnectionFactory tại một module hạ tầng dùng chung (common-infra), các module nghiệp vụ chỉ inject và cấu hình serializer riêng:
@Configuration("sharedRedisInfrastructureConfiguration")
public class SharedRedisInfrastructureConfiguration {
@Bean("sharedRedisConnectionFactory")
@Primary
public RedisConnectionFactory sharedRedisConnectionFactory(RedisProperties redisProperties) {
RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration(
redisProperties.getHost(),
redisProperties.getPort()
);
GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(50);
poolConfig.setMaxIdle(20);
poolConfig.setMinIdle(5);
LettucePoolingClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder()
.poolConfig(poolConfig)
.commandTimeout(Duration.ofMillis(2000))
.build();
return new LettuceConnectionFactory(serverConfig, clientConfig);
}
}# cô lập ranh giới persistence (jpa & database)
Một sai lầm "chết người" khác trong kiến trúc Spring Boot là đặt @EntityScan và @EnableJpaRepositories ở main class Application quét toàn bộ package gốc (ví dụ: basePackages = "com.company.*").
Hành vi này dẫn đến việc:
- Hibernate Session Factory nạp toàn bộ Entity của tất cả các domain vào cùng một Persistence Unit.
- Lập trình viên dễ dàng tạo quan hệ
@ManyToOnehoặc@ManyToManyxuyên qua các module khác nhau, biến database thành một mớ spaghetti không thể phân tách. - Tốc độ startup chậm chạp và unit test của một module buộc phải dựng schema của toàn bộ ứng dụng.
# thiết lập cô lập jpa cho từng module
package com.company.billing.infra.persistence;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Configuration("billingJpaConfiguration")
@EntityScan(basePackages = {
"com.company.billing.infra.persistence.entity"
})
@EnableJpaRepositories(
basePackages = {
"com.company.billing.infra.persistence.repository"
},
entityManagerFactoryRef = "billingEntityManagerFactory",
transactionManagerRef = "billingTransactionManager"
)
public class BillingJpaConfiguration {
// Chỉ rõ phạm vi EntityManager & TransactionManager của module Billing
}IMPORTANT
Quy tắc Vàng về Giao tiếp Liên Module: Tuyệt đối không dùng Foreign Key hoặc Navigation Property JPA (order.getPayment().getUser()) xuyên qua ranh giới module. Các module chỉ giao tiếp với nhau thông qua ID tham chiếu thuần túy (e.g. UUID paymentId) hoặc thông qua Domain Events bất đồng bộ (@TransactionalEventListener).
# bẫy jdbc postgresql: bytea vs stringtype
Trong nhiều dự án tài chính - ngân hàng lớn, các kiến trúc sư thường cấu hình JDBC connection URL của PostgreSQL với cờ:
jdbc:postgresql://postgres-db:5432/core_db?stringtype=unspecified
# bản chất cơ chế & nguyên nhân gốc rễ
Theo mặc định, PostgreSQL JDBC Driver gửi các tham số Java String xuống DB dưới dạng VARCHAR (OID 1043). Khi bật stringtype=unspecified, driver sẽ gửi chuỗi dưới dạng kiểu không xác định (UNKNOWN / OID 705), buộc engine của PostgreSQL phải tự suy diễn kiểu (type resolution) dựa trên ngữ cảnh câu query.
Mục đích ban đầu là hỗ trợ ép kiểu linh hoạt cho UUID, JSONB hoặc ENUM mà không cần custom JPA Type Converter. Tuy nhiên, điều này tạo ra 3 thảm họa chết người trong Hibernate 6 / Spring Boot 3:
# giải pháp kiến trúc dứt điểm
Nếu không thể loại bỏ tham số stringtype=unspecified do phụ thuộc vào các module legacy cũ, toàn bộ query liên quan phải tuân thủ chuẩn:
Chuyển sang Native SQL với cú pháp CAST(:param AS text) hoặc (?1::text):
public interface UserRepository extends JpaRepository<UserEntity, UUID> {
@Query(value = """
SELECT * FROM users u
WHERE (CAST(:email AS text) IS NULL OR u.email = CAST(:email AS text))
AND (CAST(:status AS text) IS NULL OR u.status = CAST(:status AS text))
""", nativeQuery = true)
Page<UserEntity> searchUsers(
@Param("email") String email,
@Param("status") String status,
Pageable pageable
);
}# tự động hóa kiểm soát ranh giới với archUnit
Một kiến trúc dù được thiết kế hoàn hảo đến đâu trên sơ đồ cũng sẽ bị thoái hóa theo thời gian nếu không có cơ chế rào chắn tự động trong pipeline CI/CD. Lập trình viên mới có thể vô tình import một class từ infrastructure vào domain.
ArchUnit là công cụ phân tích bytecode Java cho phép biến các nguyên tắc kiến trúc thành các bài Unit Test tự động chạy mỗi khi build:
package com.company.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@AnalyzeClasses(packages = "com.company", importOptions = ImportOption.DoNotIncludeTests.class)
public class ArchitectureEnforcementTest {
@ArchTest
public static final ArchRule domain_must_not_depend_on_frameworks =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..",
"org.hibernate.."
)
.because("Domain model phải là POJO thuần túy, độc lập tuyệt đối với Framework!");
@ArchTest
public static final ArchRule strict_hexagonal_layers =
layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("..domain..")
.layer("Application").definedBy("..application..")
.layer("Adapters").definedBy("..infra..")
.layer("Bootstrap").definedBy("..app..")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Adapters", "Bootstrap")
.whereLayer("Application").mayOnlyBeAccessedByLayers("Adapters", "Bootstrap")
.whereLayer("Adapters").mayOnlyBeAccessedByLayers("Bootstrap");
}# checklist triển khai enterprise
| Hạng mục kiểm tra | Tiêu chuẩn production bắt buộc | Hậu quả nếu vi phạm |
|---|---|---|
| Bean Naming | Mọi @Configuration và @Bean mang prefix module tường minh. | Xung đột ConflictingBeanDefinitionException khi hợp nhất code. |
| Override Safety | spring.main.allow-bean-definition-overriding=false. | Ghi đè bean âm thầm, lỗi runtime ngẫu nhiên cực khó debug. |
| Connection Pools | Tái sử dụng ConnectionFactory dùng chung (Hikari/Lettuce). | Cạn kiệt Socket Descriptors và Connection Pool của Database server. |
| JPA Boundaries | Không đặt @EntityScan bao quát ở root; mỗi module tự cấu hình. | Chậm startup time; mất tính đóng gói; rò rỉ session Hibernate. |
| Cross-Module Link | Không dùng JPA Relationship (@OneToOne, @ManyToOne) xuyên module. | Chặt đứt khả năng tách thành Microservice độc lập trong tương lai. |
| Thread Pools | Mỗi module định nghĩa Executor riêng biệt; cấm SimpleAsyncTaskExecutor. | Một tác vụ nặng của module này làm nghẽn toàn bộ luồng của module khác. |
| PostgreSQL Cast | Luôn dùng CAST(:param AS text) trong Native Query khi bật unspecified. | Sập câu query với lỗi could not determine data type of parameter. |
| ArchUnit CI Gate | Chạy toàn bộ ArchUnit tests trong pipeline build Maven/Gradle. | Mã nguồn nhanh chóng thoái hóa thành "Big Ball of Mud". |
# kết luận
Kiến trúc Multi-Module Spring Boot không đơn thuần là việc tạo ra nhiều thư mục con trong project. Đó là một cam kết kỷ luật về mặt thiết kế phần mềm: biến ranh giới logic thành ranh giới vật lý, đảo ngược các luồng phụ thuộc để bảo vệ domain cốt lõi, và áp dụng các quy chuẩn quản trị nghiêm ngặt cho Bean và tài nguyên hạ tầng.
Khi được triển khai đúng đắn, Modular Monolith đem lại tốc độ phát triển vượt trội của một codebase duy nhất, sự an toàn tuyệt đối của ACID transaction, đồng thời sẵn sàng 100% để phân tách thành các microservices độc lập trong tương lai mà không cần phải viết lại nghiệp vụ từ đầu.
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 😎 👍🏻 🚀 🔥.
On this page
- # bản đồ phụ thuộc chuẩn clean architecture
- # nguyên tắc cốt lõi của dependency rule
- # cấu hình gradle multi-module & compile avoidance
- # phân biệt api vs implementation trong compile avoidance
- # quản trị hạ tầng & connection pool
- # giải pháp: single source of truth & shared factory
- # cô lập ranh giới persistence (jpa & database)
- # thiết lập cô lập jpa cho từng module
- # bẫy jdbc postgresql: bytea vs stringtype
- # bản chất cơ chế & nguyên nhân gốc rễ
- # giải pháp kiến trúc dứt điểm
- # tự động hóa kiểm soát ranh giới với archUnit
- # checklist triển khai enterprise
- # kết luận