TungDaDev's Blog

multi-module Spring Boot với Gradle

Multi modules gradle.webp
Published on
/10 mins read/

Trong hành trình xây dựng các hệ thống backend quy mô lớn, việc tổ chức mã nguồn thành nhiều module (Multi-Project Builds) là tiêu chuẩn bắt buộc để đảm bảo tính đóng gói, ranh giới domain rõ ràng và phân chia công việc cho nhiều team.

Khi so sánh giữa Maven và Gradle, Gradle luôn là sự lựa chọn vượt trội cho các dự án phức tạp nhờ tốc độ build thần tốc (nhờ Incremental Build, Compile Avoidance, và Build Cache) cùng khả năng tùy biến linh hoạt bằng Kotlin DSL / Groovy.

Tuy nhiên, phần lớn các hướng dẫn hiện nay vẫn hướng dẫn lập trình viên sử dụng các mô hình Gradle lỗi thời từ 10 năm trước:

  • Lạm dụng khối allprojects {} và subprojects {} gây phá vỡ Configuration Cache và cản trở việc chạy song song (Parallel Execution).
  • Dùng từ khóa đã bị khai tử compile thay vì phân biệt rạch ròi giữa api và implementation.
  • Module thư viện con không thể đóng gói được JAR do xung đột giữa task bootJar và jar.
  • Hardcode phiên bản thư viện phân tán khắp nơi thay vì dùng Gradle Version Catalog (libs.versions.toml).

Bài viết này sẽ đưa bạn từ các khái niệm lỗi thời lên chuẩn mực kiến trúc build hiện đại nhất của Gradle 8.x và Spring Boot 3.x.


# tại sao subprojects {} bị coi là anti-pattern?

Trong Gradle cổ điển, lập trình viên thường nhét toàn bộ 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!
}

# tại sao các kỹ sư trưởng của Gradle khuyến nghị khai tử subprojects?

  1. Cross-Project Configuration: Cấu hình từ module cha "bắn" ngầm vào module con làm mất đi tính minh bạch (Lack of transparency). Một lập trình viên mở file build.gradle của module con sẽ không thể biết được những dependencies hay compiler flags nào đang thực sự được áp dụng.
  2. Phá vỡ Gradle Configuration Cache: Tính năng tăng tốc cốt lõi của Gradle 8+ là lưu lại bộ nhớ đệm trạng thái cấu hình. Khi dùng subprojects, sự phụ thuộc chéo giữa các project ngăn cản Gradle đóng băng cấu hình, khiến tốc độ build bị chậm đi nhiều lần.
  3. Ô nhiễm Framework: Một module core-domain thuần túy (Pure POJO) chỉ cần Java tiêu chuẩn nhưng lại bị subprojects ép phải nạp plugin Spring Boot cồng kềnh.

# compile avoidance: bí mật giữa API vs implementation

Một trong những cải tiến mang tính cách mạng của plugin java-library là tách từ khóa compile cũ thành hai chế độ phân phối phụ thuộc:

# sự khác biệt sống còn

  • implementation (Mặc định nên dùng cho 95% trường hợp): Thư viện chỉ được sử dụng nội bộ bên trong module đó và không bị rò rỉ ra bên ngoài Application Binary Interface (ABI). Khi Module A thay đổi implementation bên trong mà không đổi chữ ký hàm public, Gradle sẽ bỏ qua việc biên dịch lại các module cấp trên (Compile Avoidance), giúp giảm 70% thời gian build!
  • api: Sử dụng khi kiểu dữ liệu của thư viện đó xuất hiện trực tiếp trên các phương thức public của module hiện tại (ví dụ: return type hoặc parameter). Các module phụ thuộc vào module này sẽ nhìn thấy và dùng được trực tiếp thư viện đó.

# quản lý phụ thuộc tập trung với Gradle version catalog

Thay vì khai báo version phân tán bằng các biến ext {} dễ xung đột, Gradle 7.4+ chuẩn hóa Version Catalog thông qua tệp gradle/libs.versions.toml:

# 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" }
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 hỗ trợ auto-complete cực mượt: libs.spring.boot.starter.web.
  • Single Source of Truth: Nâng cấp phiên bản thư viện chỉ cần sửa đúng 1 dòng trong file .toml.
  • Bundles: Gom nhóm nhiều dependencies thường đi cùng nhau chỉ bằng 1 khai báo ngắn gọn implementation libs.bundles.web.stack.

# xử lý xung đột "chết người": bootjar vs jar

Một lỗi kinh điển khiến các kỹ sư mới làm quen với Spring Boot Multi-Module phải "vò đầu bứt tai":

Execution failed for task ':api-gateway:compileJava'.
> Could not resolve project :common.
  > Project :common declares a dependency from configuration 'default' to a configuration which isn't an archive.

# nguyên nhân gốc rễ

Khi apply plugin org.springframework.boot lên một module con, Spring Boot sẽ:

  1. Tự động kích hoạt task bootJar: Đóng gói ứng dụng thành một "Fat JAR" có cấu trúc đặc biệt chứa file loader riêng của Spring, phục vụ việc chạy độc lập (java -jar).
  2. TỰ ĐỘNG TẮT TASK jar TIÊU CHUẨN: jar { enabled = false }.
  3. Hậu quả: Khi một module khác phụ thuộc vào module này (implementation project(':common')), Gradle không thể tìm thấy file JAR tiêu chuẩn để nạp vào classpath, dẫn đến lỗi build sập!

# cấu hình chuẩn xác 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ấu trúc dự án hoàn chỉnh chuẩn enterprise

Xét 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 khiết)

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
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)
}

# ma trận đánh giá 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ảnRải rác biến ext {} khắp các fileTập trung trong libs.versions.toml
Kế thừa Cấu hìnhsubprojects {} / allprojects {}Convention Plugins hoặc cấu hình tường minh
Phân phối Phụ thuộccompile (đã bị xóa bỏ)api vs implementation (Compile Avoidance)
Kiểm soát ArtifactDễ lỗi mất JAR do xung đột bootJarTường minh: Runnable (bootJar) vs Library (jar)
Tốc độ BuildChậm, phải re-compile toàn bộ projectSiêu tốc nhờ tận dụng tối đa Build Cache và ABI check

# tổng kết

Chuyển đổi một dự án Spring Boot sang kiến trúc Multi-Module với Gradle không đơn thuần là việc tạo ra nhiều thư mục build.gradle. Đó là việc thiết lập một quy trình công nghệ chuẩn mực:

  • Tối ưu hóa thời gian biên dịch của cả team thông qua cơ chế implementation và Compile Avoidance.
  • Bảo vệ ranh giới kiến trúc: Giữ cho domain luôn sạch sẽ, không bị ô nhiễm bởi các annotation của Spring Boot.
  • Quản trị tập trung và an toàn: Sử dụng Version Catalog để loại bỏ hoàn toàn 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 😎 👍🏻 🚀 🔥.

← Previous postspring security