maven dependencies
Tung Bui Thanh (TungDaDev)11 mins read

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.
| Scope | Compile | Test | Runtime | Packaged |
|---|---|---|---|---|
| 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.
| Phase | Làm gì |
|---|---|
| validate | Kiểm tra POM có hợp lệ không |
| compile | Biên dịch source code |
| test | Chạy unit test |
| package | Tạo JAR/WAR |
| verify | Chạy integration test, kiểm tra chất lượng |
| install | Cà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ặp | Hậu quả | Cách sửa |
|---|---|---|
| Khai version ở child dù parent đã quản lý | Đè lên BOM, version lệch nhau | Bỏ version ở child |
Import BOM mà thiếu <type>pom</type> | Chỉ cảnh báo, BOM không được import, child thiếu version | Thêm <type>pom</type><scope>import</scope> |
Để scope compile cho thư viện test | Rác lọt vào classpath production | Dùng scope test |
| Release mà còn dependency snapshot | Build không tái lập được | Chốt về version release |
| Lombok + MapStruct thiếu binding | MapStruct không thấy getter/setter của Lombok | Thêm lombok-mapstruct-binding vào processor path |
Quên flag -am | Build module lỗi (thiếu deps) | Dùng -pl module -am |
mvn install mà không clean | Class cũ còn sót trong target | Luô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 methodChỉ 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 😎 👍🏻 🚀 🔥.