multi-module Spring Boot với Gradle
Tung Bui Thanh (TungDaDev) (updated 6 months ago)11 mins read

Hệ thống backend lớn thì gần như chắc chắn phải tách code thành nhiều module (Multi-Project Builds): để đóng gói cho gọn, ranh giới domain rõ ràng và chia việc cho nhiều team.
Giữa Maven và Gradle, mình thấy Gradle hợp hơn với dự án phức tạp nhờ build nhanh (Incremental Build, Compile Avoidance, Build Cache) và tùy biến linh hoạt bằng Kotlin DSL / Groovy.
Vấn đề là nhiều hướng dẫn hiện giờ vẫn chỉ cách dùng Gradle kiểu cũ từ 10 năm trước:
- Lạm dụng khối
allprojects {}vàsubprojects {}, khiến các project dính chặt vào nhau và chặn đường tới các tối ưu mới của Gradle (configuration on demand, Isolated Projects). - Dùng
compile(đã bị gỡ) thay vì phân biệtapivàimplementation. - Module thư viện con lỡ apply plugin Spring Boot rồi vấp lỗi ở task
bootJar. - Hardcode version thư viện rải rác khắp nơi thay vì dùng Gradle Version Catalog (
libs.versions.toml).
Bài này đi từ cách làm cũ sang cách làm hiện đại với Gradle 8.x và Spring Boot 3.x. (Gradle 9 và Spring Boot 4 đã ra từ năm 2025; các ý chính trong bài vẫn giữ nguyên, chỉ cần nâng version trong catalog.)
# tại sao subprojects {} bị coi là anti-pattern?
Gradle kiểu cũ thường nhét hết cấu hình chung vào subprojects {} ở file build.gradle gốc:
// 🔴 MÔ HÌNH LỖI THỜI CẦN LOẠI BỎ:
subprojects {
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
// Ép mọi module con phải nhận chung toàn bộ cấu hình!
}# vì sao Gradle khuyến nghị bỏ subprojects?
- Cross-Project Configuration: cấu hình từ module cha "bắn" ngầm vào module con nên mất tính minh bạch. Mở
build.gradlecủa module con ra, bạn không biết dependencies hay compiler flags nào đang thực sự được áp dụng. - Chặn đường tối ưu của Gradle: Gradle đang hướng tới chế độ Isolated Projects, trong đó mỗi project được cấu hình độc lập, song song và cache riêng. Cấu hình chéo kiểu
subprojects {}(project cha với tay vào sửa project con) vi phạm đúng điều kiện đó, nên Gradle phải cấu hình cả cây project cùng lúc, project càng nhiều càng chậm. Configuration cache thông thường vẫn chạy được, nhưng mỗi lần đổi file build gốc là toàn bộ cấu hình bị tính lại. - Lẫn framework vào module không cần: một module
core-domainthuần (Pure POJO) chỉ cần Java chuẩn mà vẫn bịsubprojectsép nạp plugin Spring Boot.
# compile avoidance: api vs implementation
Plugin java-library tách compile cũ thành hai chế độ khai báo dependency:
# khác nhau ở đâu
implementation(nên dùng cho đa số trường hợp): thư viện chỉ dùng nội bộ trong module và không lộ ra Application Binary Interface (ABI). Module C phụ thuộc B sẽ không thấy A trên compile classpath, nên khi ABI của A đổi thì chỉ B phải biên dịch lại, C thì không. Thêm một cái lợi: C không thể vô tình import class của A.Còn nếu A chỉ đổi phần bên trong (thân method, field private) mà không đổi ABI, thì Gradle bỏ qua biên dịch lại các module phía trên bất kể dùng
apihayimplementation, vì nó so ABI chứ không so file. Đó mới là Compile Avoidance. Mức tiết kiệm thời gian build tuỳ hình dạng cây module, không có con số chung.api: dùng khi kiểu dữ liệu của thư viện xuất hiện trực tiếp trên methodpubliccủa module hiện tại (ví dụ return type hoặc parameter). Module nào phụ thuộc vào module này cũng thấy và dùng được thư viện đó.
# quản lý phụ thuộc tập trung với Gradle version catalog
Thay vì khai báo version rải rác bằng biến ext {} dễ xung đột, từ Gradle 7.4+ có Version Catalog qua file gradle/libs.versions.toml (các version dưới đây là ở thời điểm viết bài):
# gradle/libs.versions.toml
[versions]
springBoot = "3.3.4"
springDependencyManagement = "1.1.6"
lombok = "1.18.34"
mapstruct = "1.5.5.Final"
postgresql = "42.7.4"
[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" }
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" }
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }
mapstruct = { module = "org.mapstruct:mapstruct", version.ref = "mapstruct" }
[bundles]
web-stack = ["spring-boot-starter-web", "spring-boot-starter-validation"]
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" }
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagement" }# lợi ích của version catalog
- Type-Safe Accessors: trong file build Kotlin/Groovy, IDE auto-complete được luôn:
libs.spring.boot.starter.web. - Single Source of Truth: nâng version thư viện chỉ sửa đúng 1 dòng trong file
.toml. - Bundles: gom các dependency hay đi chung, chỉ cần một dòng
implementation libs.bundles.web.stack.
Các starter của Spring Boot không ghi version trong catalog vì version do plugin io.spring.dependency-management (đọc BOM của Spring Boot) quyết định. Catalog chỉ khai báo "có thư viện này", không tự áp version cho dependency bắc cầu như BOM.
# xung đột bootjar vs jar
Lỗi kinh điển khi mới làm Spring Boot Multi-Module, ai cũng từng vò đầu một lần:
Execution failed for task ':common:bootJar'.
> Error while evaluating property 'mainClass' of task ':common:bootJar'.
> Failed to calculate the value of task ':common:bootJar' property 'mainClass'.
> Main class name has not been configured and it could not be resolved from classpath# nguyên nhân
Khi apply plugin org.springframework.boot lên một module con, Spring Boot sẽ:
- Bật task
bootJar: đóng gói ứng dụng thành "Fat JAR" có loader riêng của Spring, để chạy độc lập (java -jar). Task này cần một class cómain; module thư viện thì không có, nên./gradlew buildlỗi như trên. - Đổi tên JAR thường: từ Spring Boot 2.5, task
jarvẫn chạy nhưng file ra có hậu tố-plainđể khỏi đè lên fat JAR. (Ở Spring Boot 2.0–2.4, taskjarbị tắt hẳn khi cóbootJar, nên ngày đó tắtbootJarxong còn phải bật lạijar.) - Module khác vẫn dùng được: khi một module khác khai báo
implementation project(':common'), Gradle lấy output của taskjar(bản plain) chứ không lấy fat JAR. Rắc rối thật sự chỉ làbootJarđòi main class, cộng thêm việc build ra một fat JAR vô dụng.
# cấu hình cho module thư viện (common/build.gradle)
// Tắt task đóng gói Fat JAR thực thi
bootJar {
enabled = false
}
// Bắt buộc bật lại task sinh JAR Java tiêu chuẩn cho các module khác import!
jar {
enabled = true
archiveClassifier = '' // Đảm bảo tên file jar không bị đính kèm hậu tố lạ
}Cách gọn hơn nữa là đừng apply plugin org.springframework.boot cho module thư viện. Module đó chỉ cần quản lý version, nên apply io.spring.dependency-management và import BOM của Spring Boot (mavenBom(SpringBootPlugin.BOM_COORDINATES)), hoặc dùng implementation(platform(...spring-boot-dependencies...)). Tài liệu Spring Boot Gradle plugin gọi cách này là dùng dependency management "in isolation".
# cấu trúc dự án mẫu
Lấy ví dụ một hệ thống E-Commerce Modular Monolith:
my-enterprise-app/
├── gradle/
│ └── libs.versions.toml # Danh mục quản lý phiên bản tập trung
├── settings.gradle.kts # Định nghĩa cấu trúc phân tầng modules
├── build.gradle.kts # Root config tinh giản
├── domain/ # Pure Java POJO (Zero Frameworks)
│ └── build.gradle.kts
├── infrastructure/
│ ├── persistence/ # JPA, PostgreSQL, HikariCP
│ │ └── build.gradle.kts
│ └── messaging/ # Kafka, RabbitMQ
│ └── build.gradle.kts
└── application/ # Deployable Spring Boot Main Application
└── build.gradle.kts# settings.gradle.kts (bật type-safe accessors)
rootProject.name = "my-enterprise-app"
// Khai báo rõ ràng từng module con
include(":domain")
include(":infrastructure:persistence")
include(":infrastructure:messaging")
include(":application")
// Bật tính năng Type-Safe Project Accessors
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")# domain/build.gradle.kts (module nghiệp vụ thuần)
plugins {
`java-library` // Sử dụng java-library để hỗ trợ api/implementation
}
dependencies {
// KHÔNG import Spring Boot Starter! Domain phải độc lập tuyệt đối với Framework!
compileOnly(libs.lombok)
annotationProcessor(libs.lombok)
testImplementation(platform("org.junit:junit-bom:5.10.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
}# infrastructure/persistence/build.gradle.kts
plugins {
`java-library`
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
// Module hạ tầng là thư viện, không tạo file bootJar runnable
// (hoặc bỏ hẳn plugin spring.boot, chỉ giữ dependency management như đã nói ở trên)
tasks.bootJar { enabled = false }
tasks.jar { enabled = true }
dependencies {
// Phụ thuộc vào domain bằng Type-Safe Accessor
api(projects.domain)
implementation(libs.spring.boot.starter.data.jpa)
runtimeOnly(libs.postgresql)
compileOnly(libs.lombok)
annotationProcessor(libs.lombok)
}# application/build.gradle.kts (deployable host)
plugins {
application
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
// Đây là module duy nhất sinh ra file jar thực thi trên Production!
tasks.bootJar { enabled = true }
tasks.jar { enabled = false }
dependencies {
implementation(projects.domain)
implementation(projects.infrastructure.persistence)
implementation(projects.infrastructure.messaging)
implementation(libs.bundles.web.stack)
implementation(libs.spring.boot.starter.actuator)
}Một chi tiết dễ vấp: khi nhiều module cùng alias(libs.plugins.spring.boot), nên khai báo plugin đó một lần ở build.gradle.kts gốc với apply false (plugins { alias(libs.plugins.spring.boot) apply false }). Như vậy plugin chỉ được nạp vào một classloader chung, tránh các lỗi khó hiểu khi các module con nạp plugin riêng rẽ.
# bảng so sánh
| Tiêu chí | Cấu hình Cổ Điển (Legacy Groovy) | Chuẩn Enterprise Hiện Đại (Kotlin DSL) |
|---|---|---|
| Quản lý Phiên bản | Rải rác biến ext {} khắp các file | Tập trung trong libs.versions.toml |
| Kế thừa Cấu hình | subprojects {} / allprojects {} | Convention Plugins hoặc cấu hình tường minh |
| Phân phối Phụ thuộc | compile (đã bị xóa bỏ) | api vs implementation (Compile Avoidance) |
| Kiểm soát Artifact | Dễ lỗi mất JAR do xung đột bootJar | Tường minh: Runnable (bootJar) vs Library (jar) |
| Tốc độ Build | Chậm, hay re-compile cả cây module | Nhanh hơn nhờ Build Cache và ABI check |
# tổng kết
Chuyển một dự án Spring Boot sang Multi-Module với Gradle đâu chỉ là tạo thêm vài file build.gradle. Làm đúng thì được mấy thứ:
- Build nhanh hơn cho cả team nhờ
implementationvà Compile Avoidance. - Giữ ranh giới kiến trúc:
domainluôn sạch, không dính annotation của Spring Boot. - Quản lý version tập trung: Version Catalog giúp tránh các lỗi xung đột thư viện âm thầm.
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 😎 👍🏻 🚀 🔥.