Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

maven dependencies

Tung Bui Thanh (TungDaDev)11 mins read

Maven dependencies.webp

Làm microservices lâu sẽ thấy file build không chỉ để đóng gói cho xong. Nhìn pom.xml là đoán được project gọn hay rối, version có nhất quán không, sau này bảo trì khổ cỡ nào. Quản lý ẩu thì sớm muộn cũng rơi vào "Dependency Hell": hai thư viện đòi hai version khác nhau của cùng một thứ, chạy local thì ổn, lên server thì NoSuchMethodError.

Mình gom lại mấy thứ cần nắm để dùng Maven cho ngon: tổ chức POM cho gọn, quản lý version tập trung bằng BOM, scope, profile và cách dựng project multi-module.

# pom structure

Một file pom.xml gọn thì nhìn vào là thấy phần nào lo việc nấy. Đừng nhồi mọi thứ vào một chỗ: cấu hình chung để parent lo (kế thừa), con số version gom vào <properties>, version của cả họ thư viện thì để BOM quản lý.

<project>
   <!-- Coordinates -->
   <groupId>com.tungdadev</groupId>
   <artifactId>my-service</artifactId>
   <version>1.0.0-SNAPSHOT</version>
   <packaging>jar</packaging>
 
   <!-- Parent (inherits config) -->
   <parent>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-parent</artifactId>
       <version>3.2.5</version>
   </parent>
 
   <!-- Properties (centralize versions) -->
   <properties>
       <java.version>21</java.version>
       <mapstruct.version>1.5.5.Final</mapstruct.version>
   </properties>
 
   <!-- Dependency Management (BOM imports) -->
   <dependencyManagement>
       <dependencies>
           <dependency>
               <groupId>org.springframework.cloud</groupId>
               <artifactId>spring-cloud-dependencies</artifactId>
               <version>2023.0.1</version>
               <type>pom</type>
               <scope>import</scope>
           </dependency>
       </dependencies>
   </dependencyManagement>
 
   <!-- Actual Dependencies -->
   <dependencies>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-web</artifactId>
           <!-- version inherited from parent -->
       </dependency>
   </dependencies>
</project>

Version Spring Boot và Spring Cloud ở đây chỉ để minh hoạ, khi nâng thì nhớ Spring Cloud release train phải khớp với dòng Spring Boot (ví dụ 2023.0.x đi với Boot 3.2/3.3), xem bảng tương thích trên trang Spring Cloud.

# dependency scopes

Hiểu đúng scope thì file JAR/WAR cuối cùng nhẹ hơn, classpath production ít thứ thừa hơn, ít thư viện phải vá CVE hơn.

ScopeCompileTestRuntimePackaged
compile (default)✓✓✓✓
provided✓✓✗✗
runtime✗✓✓✓
test✗✓✗✗
system✓✓✗✗

provided nghĩa là "môi trường chạy sẽ có sẵn", ví dụ servlet container lo jakarta.servlet-api. system giống provided nhưng trỏ thẳng tới một file JAR trên máy qua <systemPath>, đã bị đánh dấu deprecated; thư viện không có trên repo nào thì nên đẩy lên Nexus nội bộ thay vì dùng system.

Ví dụ:

<!-- Examples -->
<dependency>
   <groupId>org.projectlombok</groupId>
   <artifactId>lombok</artifactId>
   <scope>provided</scope> <!-- annotation processor, not in final jar -->
</dependency>
 
<dependency>
   <groupId>org.postgresql</groupId>
   <artifactId>postgresql</artifactId>
   <scope>runtime</scope> <!-- only needed at runtime via JDBC -->
</dependency>
 
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-test</artifactId>
   <scope>test</scope>
</dependency>

# bom (bill of materials)

Project lớn hay hệ thống microservices mà module nào cũng hardcode version là anti-pattern. BOM (Bill of Materials) là một file POM chỉ chứa <dependencyManagement>, tức danh sách "thư viện nào dùng version nào". Import nó vào thì mọi version chỉ khai báo ở một chỗ duy nhất.

Cần phân biệt hai thứ nằm chung trong <dependencyManagement>. Một dòng có <type>pom</type><scope>import</scope> là import cả BOM (kéo về hàng trăm version quản lý sẵn). Một dòng bình thường chỉ là chốt version cho đúng một thư viện, không tự thêm thư viện đó vào project, module nào cần thì vẫn phải khai báo trong <dependencies>.

Ở file Parent hoặc Shared BOM:

<!-- In parent or shared BOM -->
<dependencyManagement>
   <dependencies>
       <!-- Spring Cloud BOM -->
       <dependency>
           <groupId>org.springframework.cloud</groupId>
           <artifactId>spring-cloud-dependencies</artifactId>
           <version>${spring-cloud.version}</version>
           <type>pom</type>
           <scope>import</scope>
       </dependency>
 
       <!-- Internal library: chốt version, không phải import BOM -->
       <dependency>
           <groupId>com.tungdadev</groupId>
           <artifactId>csp-common</artifactId>
           <version>${csp-common.version}</version>
       </dependency>
   </dependencies>
</dependencyManagement>

Ở các child module thì gọn hẳn, không còn thẻ <version> nào:

<!-- In child module — no version needed -->
<dependencies>
   <dependency>
       <groupId>org.springframework.cloud</groupId>
       <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
       <!-- version resolved from BOM -->
   </dependency>
</dependencies>

# dependency resolution

Khi hai thư viện cùng kéo theo (transitive dependency) một thư viện thứ ba nhưng khác version, Maven theo luật "nearest wins": cái nào nằm gần project của mình hơn trong cây dependency thì thắng. Hai cái cùng độ sâu thì cái khai báo trước trong POM thắng. Maven không chọn version mới nhất, nên rất có thể bạn đang chạy bản cũ hơn bản một thư viện cần, và chỉ phát hiện lúc runtime.

Muốn ép đúng version mình cần thì khai báo version đó trong <dependencyManagement> (áp dụng cho cả transitive) hoặc khai báo trực tiếp dependency. Còn muốn gạt bỏ hẳn một thư viện kéo theo, ví dụ bỏ Tomcat để dùng Jetty hay Undertow, thì dùng exclusions:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>

# useful commands

# Hiển thị cây thư viện để truy vết nguồn gốc dependency
./mvnw dependency:tree
 
# Lọc nhanh các xung đột
./mvnw dependency:tree -Dverbose | grep "omitted for conflict"
 
# Phát hiện các dependency đang được khai báo nhưng không dùng tới
./mvnw dependency:analyze
 
# Show effective POM (resolved inheritance)
./mvnw help:effective-pom
 
# Show effective settings
./mvnw help:effective-settings

# profiles

<profiles>
   <!-- Dev profile (default) -->
   <profile>
       <id>dev</id>
       <activation>
           <activeByDefault>true</activeByDefault>
       </activation>
       <properties>
           <packaging.type>jar</packaging.type>
           <spring.profiles.active>dev</spring.profiles.active>
       </properties>
   </profile>
 
   <!-- Production profile -->
   <profile>
       <id>prod</id>
       <properties>
           <packaging.type>war</packaging.type>
           <spring.profiles.active>prod</spring.profiles.active>
       </properties>
       <dependencies>
           <dependency>
               <groupId>org.springframework.boot</groupId>
               <artifactId>spring-boot-starter-tomcat</artifactId>
               <scope>provided</scope>
           </dependency>
       </dependencies>
   </profile>
 
   <!-- Skip tests profile -->
   <profile>
       <id>fast</id>
       <properties>
           <maven.test.skip>true</maven.test.skip>
           <checkstyle.skip>true</checkstyle.skip>
       </properties>
   </profile>
</profiles>

Mấy chỗ hay vấp với đoạn trên. Property packaging.type chỉ có tác dụng khi POM khai báo <packaging>${packaging.type}</packaging>, còn để cứng <packaging>jar</packaging> như ví dụ đầu bài thì profile prod vẫn ra JAR. Muốn chạy WAR trên Tomcat ngoài thì class main còn phải kế thừa SpringBootServletInitializer. Profile có activeByDefault sẽ tự tắt khi bạn bật bất kỳ profile nào khác bằng -P: chạy -Pfast thì dev không còn active, nên ví dụ dưới mới ghi -Pdev,fast. Còn maven.test.skip=true bỏ qua cả bước compile test, khác với -DskipTests là vẫn compile test nhưng không chạy.

# Activate profile
./mvnw clean package -Pprod
./mvnw clean package -Pdev,fast
 
# Property override
./mvnw clean package -DskipTests

# multi-module project

Hệ thống theo Clean Architecture hay Domain-Driven Design thường chia code thành các module riêng (core, application, infrastructure). Maven multi-module giữ các module này chung một project, mỗi module một pom.xml và một JAR riêng. Cái lợi lớn nhất là ranh giới được compiler giữ hộ: core không khai báo dependency tới infrastructure thì không import nhầm class của nó được. Maven tự sắp thứ tự build theo dependency giữa các module (reactor), không theo thứ tự trong <modules>.

Parent POM giữ danh sách các module con:

<modules>
    <module>core</module>
    <module>application</module>
    <module>infrastructure</module>
</modules>

# build commands

# Build all modules
./mvnw clean install
 
# Build specific module + dependencies
./mvnw clean install -pl application -am
 
# Build specific module only (no deps)
./mvnw clean install -pl bpm-cluster
 
# Skip modules
./mvnw clean install -pl '!integration-tests'

Flags:

  • -pl (projects list): chọn module cần build
  • -am (also make): build luôn các module mà nó phụ thuộc
  • -amd (also make dependents): build luôn các module phụ thuộc vào nó
  • -rf (resume from): build tiếp từ một module cụ thể

Dấu ! trong -pl nên để trong nháy đơn, vì bash/zsh coi ! là history expansion.

# build lifecycle

# default lifecycle phases

Maven chạy tuần tự. Gọi một phase là mọi phase đứng trước nó cũng chạy: validate → compile → test → package (tạo JAR) → verify (kiểm tra kết quả integration test, coverage, Checkstyle...) → install (đưa vào local repo) → deploy (đẩy lên Nexus).

Bảng dưới là các phase chính. Thật ra lifecycle còn nhiều phase ở giữa như process-resources, test-compile, integration-test; Failsafe chạy integration test ở integration-test rồi tới verify mới báo lỗi, để kịp dọn dẹp môi trường ở post-integration-test.

PhaseLàm gì
validateKiểm tra POM có hợp lệ không
compileBiên dịch source code
testChạy unit test
packageTạo JAR/WAR
verifyChạy integration test, kiểm tra chất lượng
installCài vào local repo .m2
deployĐẩy lên Nexus repo
# Each phase runs all preceding phases
./mvnw package    # → validate → compile → test → package
./mvnw verify     # → ... → package → verify (JaCoCo, Fortify)
./mvnw install    # → ... → verify → install

# plugin configuration

<build>
   <plugins>
       <!-- Compiler -->
       <plugin>
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-compiler-plugin</artifactId>
           <configuration>
               <release>21</release>
               <compilerArgs>
                   <arg>--enable-preview</arg>
               </compilerArgs>
               <annotationProcessorPaths>
                   <path>
                       <groupId>org.projectlombok</groupId>
                       <artifactId>lombok</artifactId>
                       <version>${lombok.version}</version>
                   </path>
                   <path>
                       <groupId>org.mapstruct</groupId>
                       <artifactId>mapstruct-processor</artifactId>
                       <version>${mapstruct.version}</version>
                   </path>
                   <path>
                       <groupId>org.projectlombok</groupId>
                       <artifactId>lombok-mapstruct-binding</artifactId>
                       <version>0.2.0</version>
                   </path>
               </annotationProcessorPaths>
           </configuration>
       </plugin>
 
       <!-- Spring Boot packaging -->
       <plugin>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-maven-plugin</artifactId>
           <configuration>
               <excludes>
                   <exclude>
                       <groupId>org.projectlombok</groupId>
                       <artifactId>lombok</artifactId>
                   </exclude>
               </excludes>
           </configuration>
       </plugin>
 
       <!-- JaCoCo coverage -->
       <plugin>
           <groupId>org.jacoco</groupId>
           <artifactId>jacoco-maven-plugin</artifactId>
           <version>0.8.12</version>
           <executions>
               <execution>
                   <goals><goal>prepare-agent</goal></goals>
               </execution>
               <execution>
                   <id>check</id>
                   <phase>verify</phase>
                   <goals><goal>check</goal></goals>
                   <configuration>
                       <rules>
                           <rule>
                               <limits>
                                   <limit>
                                       <counter>LINE</counter>
                                       <minimum>0.80</minimum>
                                   </limit>
                               </limits>
                           </rule>
                       </rules>
                   </configuration>
               </execution>
           </executions>
       </plugin>
   </plugins>
</build>

Ghi chú cho đoạn trên. ${lombok.version} lấy từ BOM của Spring Boot, còn mapstruct.version mình tự khai ở <properties>. Lombok từ 1.18.16 trở đi cần thêm lombok-mapstruct-binding thì MapStruct mới thấy getter/setter do Lombok sinh ra. JaCoCo không nằm trong danh sách plugin Spring Boot parent quản lý version, nên phải ghi version rõ ràng. Cờ --enable-preview chỉ nên bật khi thật sự dùng tính năng preview: class build ra chỉ chạy được trên đúng bản Java đó, và Surefire lẫn lệnh java lúc chạy cũng phải thêm --enable-preview.

# private repository (nexus)

# settings.xml

<settings>
   <servers>
       <server>
           <id>nexus-releases</id>
           <username>${env.NEXUS_USER}</username>
           <password>${env.NEXUS_PASS}</password>
       </server>
       <server>
           <id>nexus-snapshots</id>
           <username>${env.NEXUS_USER}</username>
           <password>${env.NEXUS_PASS}</password>
       </server>
   </servers>
 
   <mirrors>
       <mirror>
           <id>nexus</id>
           <mirrorOf>*</mirrorOf>
           <url>https://nexus.company.com/repository/maven-public/</url>
       </mirror>
   </mirrors>
</settings>

# deploy to nexus

<distributionManagement>
   <repository>
       <id>nexus-releases</id>
       <url>https://nexus.company.com/repository/maven-releases/</url>
   </repository>
   <snapshotRepository>
       <id>nexus-snapshots</id>
       <url>https://nexus.company.com/repository/maven-snapshots/</url>
   </snapshotRepository>
</distributionManagement>
./mvnw deploy -s settings.xml

# resource filtering

Filtering cho phép Maven thay giá trị property vào file resource lúc build, ví dụ đưa version của project vào application.yml. Lưu ý nếu project kế thừa spring-boot-starter-parent thì placeholder phải viết dạng @project.version@, không phải ${...}: parent đổi delimiter sang @ để khỏi đụng với placeholder ${...} của chính Spring. Parent cũng đã bật sẵn filtering cho application*.yml, application*.yaml, application*.properties, nên đoạn dưới chỉ cần khi bạn không dùng Boot parent hoặc muốn đổi danh sách file.

<build>
   <resources>
       <resource>
           <directory>src/main/resources</directory>
           <filtering>true</filtering> <!-- replace ${...} placeholders -->
           <includes>
               <include>application.yml</include>
           </includes>
       </resource>
       <resource>
           <directory>src/main/resources</directory>
           <filtering>false</filtering> <!-- don't corrupt binary files -->
           <excludes>
               <exclude>application.yml</exclude>
           </excludes>
       </resource>
   </resources>
</build>

# common pitfalls

Lỗi hay gặpHậu quảCách sửa
Khai version ở child dù parent đã quản lýĐè lên BOM, version lệch nhauBỏ version ở child
Import BOM mà thiếu <type>pom</type>Chỉ cảnh báo, BOM không được import, child thiếu versionThêm <type>pom</type><scope>import</scope>
Để scope compile cho thư viện testRác lọt vào classpath productionDùng scope test
Release mà còn dependency snapshotBuild không tái lập đượcChốt về version release
Lombok + MapStruct thiếu bindingMapStruct không thấy getter/setter của LombokThêm lombok-mapstruct-binding vào processor path
Quên flag -amBuild module lỗi (thiếu deps)Dùng -pl module -am
mvn install mà không cleanClass cũ còn sót trong targetLuôn clean install

# quick reference

# Common workflows
./mvnw clean package -Pdev              # Build for dev
./mvnw clean package -Pprod             # Build WAR for production
./mvnw clean verify -s settings.xml     # Build + coverage check
./mvnw clean install -pl core -am       # Build core + its deps
./mvnw dependency:tree                  # Show dep tree
./mvnw versions:display-dependency-updates  # Check for updates
./mvnw spring-boot:run                  # Run locally
./mvnw test -Dtest=UserServiceTest      # Run specific test class
./mvnw test -Dtest="UserServiceTest#testCreate"  # Run specific method

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 😎 👍🏻 🚀 🔥.