Spring Boot 4.0 多模块项目搭建教程:从零跑通企业级工程
前言
刚开始学 Spring Boot 时,一个 src/main/java 能放完所有代码。项目一旦开始接订单、库存、用户和后台管理,controller、service、工具类、第三方调用就会挤在同一层。代码还能运行,但改一个模块时,很难判断会牵动哪里。
多模块不是为了让目录看起来“高级”。它真正解决的是边界问题:哪些代码可以复用,哪些代码只能给 Web 层使用,一个模块变化后需要重新测试什么。
这篇教程会从空目录开始,搭建一个基于 Spring Boot 4.0.3 和 JDK 21 的三模块工程:
demo-common:放跨模块共用的数据结构。demo-order:放订单业务,不依赖 Web。demo-web:提供 REST 接口并负责把业务对象装配成 Spring Bean。
最后会得到一个能创建订单、查询订单、返回统一错误格式的可运行项目。文中代码已经实际编译并启动验证,第一次做多模块项目也可以照着完成。
本篇阶段的项目长什么样?
先看本篇完成后的目录。不要急着复制文件,后面会按顺序创建。后续第 04 篇会在此基础上增加 demo-greeting-starter,所以开源仓库最终会比这里多一个模块。
spring-boot4-demo/
├── pom.xml
├── demo-common/
│ ├── pom.xml
│ └── src/main/java/com/susan/training/common/ApiResponse.java
├── demo-order/
│ ├── pom.xml
│ └── src/
│ ├── main/java/com/susan/training/order/
│ │ ├── CreateOrderCommand.java
│ │ ├── OrderNotFoundException.java
│ │ ├── OrderService.java
│ │ ├── OrderStatus.java
│ │ └── OrderView.java
│ └── test/java/com/susan/training/order/OrderServiceTest.java
└── demo-web/
├── pom.xml
└── src/
├── main/java/com/susan/training/
│ ├── DemoApplication.java
│ ├── config/OrderConfiguration.java
│ └── web/
│ ├── CreateOrderRequest.java
│ ├── GlobalExceptionHandler.java
│ └── OrderController.java
└── main/resources/application.yml模块之间的依赖方向只有一条:

图 1:模块依赖只能从上往下,公共模块不能反向依赖业务或 Web。
demo-web 可以调用 demo-order,demo-order 可以调用 demo-common。反过来不允许。这是整套工程最重要的一条约束。
如果 demo-order 反过来依赖 demo-web,业务模块就会知道 HTTP、Controller 和请求参数,未来想把它交给定时任务或消息消费者复用时,又会带上一串 Web 依赖。
开工前先确认环境
Spring Boot 4.0 基于 Spring Framework 7。这个示例使用 JDK 21 作为编译目标,需要准备:
| 工具 | 本文使用版本 | 检查命令 | 预期结果 |
|---|---|---|---|
| JDK | 21 或更高 | java -version | 显示 21、25 等可运行版本 |
| Maven | 3.9.x | mvn -version | 显示 Maven 和 Java 版本 |
| IDE | IntelliJ IDEA 或 VS Code | 能打开 Maven 项目 | 不需要额外插件 |
执行:
java -version
mvn -version如果 mvn 提示找不到命令,需要先把 Maven 的 bin 目录加入环境变量。Windows 用户还要确认终端里的 Java 和 IDE 项目 SDK 是同一个版本。
本文固定使用 Spring Boot 4.0.3,是为了给初学者一个可重复的结果。Spring Boot 4.0.x 后续补丁版本通常可以按同样方式升级,但升级前仍应查看对应版本的 Release Notes。
还有一个新手容易混淆的地方:Spring Boot 4.0 的父 POM 默认 Java 版本并不等于本文的选择。这个项目在根 POM 中明确设置了 java.version=21,所以编译结果以 Java 21 为准。JDK 21 是长期支持版本,也更适合后续练习虚拟线程。
第 1 步:创建项目骨架
打开终端,选择一个自己常用的代码目录。macOS、Linux 或 Git Bash 可以执行:
mkdir -p spring-boot4-demo/demo-common
mkdir -p spring-boot4-demo/demo-order
mkdir -p spring-boot4-demo/demo-web
cd spring-boot4-demoWindows PowerShell 对应的是:
New-Item -ItemType Directory -Force spring-boot4-demo/demo-common
New-Item -ItemType Directory -Force spring-boot4-demo/demo-order
New-Item -ItemType Directory -Force spring-boot4-demo/demo-web
Set-Location spring-boot4-demo也可以在 IDEA 里新建一个空目录,再手动创建三个子目录。无论用哪种方式,完成后的第一层目录都应该只有根 pom.xml 和三个模块目录。
第 2 步:编写根 POM
在项目根目录创建 pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 统一继承 Spring Boot 4.0.3 的依赖版本和 Maven 插件配置。 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.3</version>
<relativePath/>
</parent>
<groupId>com.susan.training</groupId>
<artifactId>demo-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<!-- 根工程只负责聚合,三个子模块按依赖方向构建。 -->
<modules>
<module>demo-common</module>
<module>demo-order</module>
<module>demo-web</module>
</modules>
<properties>
<!-- 示例统一以 Java 21 编译,避免不同电脑的默认 JDK 造成结果差异。 -->
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>根 POM 做三件事:
- 从 Spring Boot 父工程继承依赖版本和插件配置。
- 用
<packaging>pom</packaging>表示它只负责组织子模块,不生产业务 JAR。 - 用
<modules>告诉 Maven 构建时按顺序处理三个子模块。
<relativePath/> 是空值,意思是不要到上级目录查找自己的父 POM,而是从 Maven 仓库读取 Spring Boot 父工程。少了它,Maven 可能误找本地路径,IDEA 也会出现父工程无法解析的提示。
先在根目录执行一次:
mvn -q validate这一步不会编译业务代码,只检查 POM 是否能被 Maven 正确读取。没有输出通常表示成功;如果出现 XML 报错,先检查标签是否闭合,不要继续往下写 Java。
第 3 步:创建公共模块
3.1 demo-common 的 POM
创建 demo-common/pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.susan.training</groupId>
<artifactId>demo-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<!-- 公共模块只放跨业务复用的稳定结构,不依赖 Web 或数据库。 -->
<artifactId>demo-common</artifactId>
</project>这里的 <relativePath>../pom.xml</relativePath> 指向上一级根 POM。子模块不用重复写版本号,因为 groupId 和 version 都从父工程继承。
3.2 定义统一响应
创建 demo-common/src/main/java/com/susan/training/common/ApiResponse.java:
package com.susan.training.common;
import java.time.Instant;
public record ApiResponse<T>(
String code,
String message,
T data,
Instant timestamp
) {
/**
* 创建成功响应。
*/
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>("0", "success", data, Instant.now());
}
/**
* 创建失败响应,并保留稳定的业务错误码。
*/
public static <T> ApiResponse<T> failure(String code, String message) {
return new ApiResponse<>(code, message, null, Instant.now());
}
}record 会为四个字段自动生成构造器和读取方法。T 表示成功时返回的数据可以是订单、用户或列表,错误时 data 为 null。
统一响应的价值在于客户端不用猜接口格式。成功时永远是 code/message/data/timestamp,失败时也保持相同结构。注意,code="0" 只是本示例约定的成功码,正式项目应根据自己的错误码规范调整。
公共模块应尽量保持轻。不要把数据库连接、Web 工具和业务服务都塞进来。否则所有模块都会被迫依赖同一个“大杂烩”,最终又回到单模块耦合。
第 4 步:写订单业务模块
4.1 demo-order 的 POM
创建 demo-order/pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.susan.training</groupId>
<artifactId>demo-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>demo-order</artifactId>
<dependencies>
<!-- 业务模块可以复用公共响应,但不能反向依赖 Web 模块。 -->
<dependency>
<groupId>com.susan.training</groupId>
<artifactId>demo-common</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 测试依赖只在执行测试时生效,不会打入最终运行 JAR。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>spring-boot-starter-test 只在测试阶段生效,不会进入最终运行依赖。demo-common 使用 ${project.version},是因为三个模块有同一个父版本,避免手写版本号后忘记同步。
4.2 定义订单对象
在 demo-order/src/main/java/com/susan/training/order/ 下创建四个文件。
CreateOrderCommand.java 表示业务层接收的创建命令:
package com.susan.training.order;
import java.math.BigDecimal;
/**
* 创建订单的业务命令,不依赖 HTTP 校验注解。
*/
public record CreateOrderCommand(
String customerId,
BigDecimal amount
) {
}OrderStatus.java 保存订单状态:
package com.susan.training.order;
/**
* 教程阶段只保留创建成功状态。
*/
public enum OrderStatus {
CREATED
}真实业务还会有支付中、已支付、已发货、已取消等状态。只讲多模块时,状态越少越容易看清依赖关系。
OrderView.java 是对外返回的订单视图:
package com.susan.training.order;
import java.math.BigDecimal;
/**
* 订单查询视图,只暴露调用方需要看到的数据。
*/
public record OrderView(
String orderNo,
String customerId,
BigDecimal amount,
OrderStatus status
) {
}金额不要用 double。BigDecimal 更适合表示货币,也能避免 0.1 + 0.2 这类二进制小数误差。
OrderNotFoundException.java 表达业务异常:
package com.susan.training.order;
/**
* 这是业务异常,不应该让业务模块直接依赖 HTTP 404。
*/
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(String orderNo) {
super("订单不存在: " + orderNo);
}
}它属于订单业务,不属于 Web。以后无论从 REST 接口、定时任务还是消息消费者查询订单,都可以复用这个异常。
4.3 编写订单服务
创建 OrderService.java:
package com.susan.training.order;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 订单领域服务。
*
* <p>本阶段使用内存 Map 保存数据,后续接入数据库时只替换模块内部实现。</p>
*/
public class OrderService {
// 并发 Map 只用于保护示例中的并发请求,不能替代数据库。
private final Map<String, OrderView> orders = new ConcurrentHashMap<>();
/**
* 创建订单并保存到当前进程的内存中。
*/
public OrderView create(CreateOrderCommand command) {
// UUID 片段仅用于教学演示,生产订单号还要考虑唯一性和防猜测。
String orderNo = "SO" + UUID.randomUUID()
.toString()
.replace("-", "")
.substring(0, 12)
.toUpperCase(Locale.ROOT);
// 新订单从 CREATED 状态开始。
OrderView order = new OrderView(
orderNo,
command.customerId(),
command.amount(),
OrderStatus.CREATED
);
orders.put(orderNo, order);
return order;
}
/**
* 根据订单号查询订单,找不到时抛出业务异常。
*/
public OrderView findByOrderNo(String orderNo) {
OrderView order = orders.get(orderNo);
if (order == null) {
throw new OrderNotFoundException(orderNo);
}
return order;
}
}这里故意没有写 @Service。业务模块目前是一个普通 Java 模块,只依赖 JDK,不依赖 Spring。ConcurrentHashMap 只是为了让示例在内存中跑起来,重启服务后数据会消失,不能替代数据库。
订单号使用 UUID 片段只是为了演示。正式订单号通常还要考虑可读性、防猜测、业务前缀和唯一性,不能直接把这个实现搬进生产。
第 5 步:实现 Web 模块
5.1 demo-web 的 POM
创建 demo-web/pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.susan.training</groupId>
<artifactId>demo-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>demo-web</artifactId>
<dependencies>
<!-- Web 模块依赖订单业务,由它完成 HTTP 协议适配。 -->
<dependency>
<groupId>com.susan.training</groupId>
<artifactId>demo-order</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Spring Boot 4 创建 MVC 应用时优先使用细分的 webmvc Starter。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- 提供 Jakarta Bean Validation 的参数校验实现。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 提供健康检查和后续生产监控的基础能力。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- 把 Web 模块及依赖的业务模块重新打包为可执行 JAR。 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>Spring Boot 4.0 对 Starter 做了更细的拆分。创建 MVC 应用时优先使用 spring-boot-starter-webmvc,而不是继续照抄旧教程里的 spring-boot-starter-web。后者仍有兼容入口,但依赖说明已经把它标记为推荐改用 webmvc。
demo-web 依赖 demo-order,所以它能看到订单服务;demo-order 看不到 Controller。依赖方向与项目边界一致。
5.2 创建启动类
创建 demo-web/src/main/java/com/susan/training/DemoApplication.java:
package com.susan.training;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 应用启动入口,放在根包下以扫描各业务模块的 Bean。
*/
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
// 启动 Spring 容器和嵌入式 Tomcat。
SpringApplication.run(DemoApplication.class, args);
}
}启动类放在 com.susan.training,而不是只放在 com.susan.training.web。@SpringBootApplication 默认扫描启动类所在包及子包,这样订单模块的 com.susan.training.order、配置类和 Web 类都能被扫描到。
如果启动类放得太深,运行时会出现“Controller 明明写了,访问却一直 404”或者“找不到 Bean”。这类问题通常不是 Spring Boot 坏了,而是包扫描范围没有覆盖到。
5.3 装配业务 Bean
订单服务是普通 Java 类,不会自动进入 Spring 容器。在 demo-web/src/main/java/com/susan/training/config/OrderConfiguration.java 中显式装配:
package com.susan.training.config;
import com.susan.training.order.OrderService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Web 层的 Bean 装配配置。
*
* <p>订单模块保持普通 Java,由这里把 OrderService 加入 Spring 容器。</p>
*/
@Configuration
public class OrderConfiguration {
/**
* 创建订单服务实例,供 Controller 通过构造器注入。
*/
@Bean
public OrderService orderService() {
return new OrderService();
}
}这里有一个值得记住的设计取舍。
也可以给 demo-order 添加 Spring 依赖,然后直接在 OrderService 上加 @Service。代码会更短,但订单模块从此和 Spring 容器绑定。当前写法让业务模块保持普通 Java,由 Web 模块负责装配。以后做单元测试时,直接 new OrderService() 就能测试,不需要启动 Spring。
如果团队后续决定整个业务层都使用 Spring 的事务、缓存等能力,再引入相应依赖也合理。关键是这个决定要有明确理由,而不是每个模块都无脑加 @Service。
5.4 接收 HTTP 参数
创建 demo-web/src/main/java/com/susan/training/web/CreateOrderRequest.java:
package com.susan.training.web;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
/**
* 创建订单的 HTTP 请求对象。
*/
public record CreateOrderRequest(
@NotBlank(message = "客户编号不能为空")
String customerId,
@NotNull(message = "订单金额不能为空")
@DecimalMin(value = "0.01", message = "订单金额必须大于 0")
BigDecimal amount
) {
}参数校验注解放在 Web 请求对象上,而不是直接放到业务命令里。HTTP 请求需要“字符串不能为空”,业务命令更关心“客户是否真实存在”。两者的校验时机和错误表达并不完全相同。
jakarta.validation 包名来自 Jakarta EE 11 基线。不要继续导入旧教程里的 javax.validation,这两个包并不兼容。
5.5 编写 Controller
创建 OrderController.java:
package com.susan.training.web;
import com.susan.training.common.ApiResponse;
import com.susan.training.order.CreateOrderCommand;
import com.susan.training.order.OrderService;
import com.susan.training.order.OrderView;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* 订单 REST 接口,只处理 HTTP 协议,业务规则交给 OrderService。
*/
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
// 构造器注入让依赖关系明确,也方便测试时替换实现。
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
/**
* 创建订单,成功时返回 201 Created。
*/
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<OrderView> create(@Valid @RequestBody CreateOrderRequest request) {
// HTTP 请求对象在这里转换成不依赖 Web 的业务命令。
OrderView order = orderService.create(
new CreateOrderCommand(request.customerId(), request.amount())
);
return ApiResponse.success(order);
}
/**
* 查询订单,找不到时由全局异常处理器返回 404。
*/
@GetMapping("/{orderNo}")
public ApiResponse<OrderView> find(@PathVariable String orderNo) {
return ApiResponse.success(orderService.findByOrderNo(orderNo));
}
}Controller 只做三件事:接收 HTTP 参数、调用业务命令、包装返回结果。订单如何创建、如何查询,都留在 OrderService。
构造函数注入比字段注入更容易写单元测试,也能让依赖在对象创建时就被确定。Spring Boot 4 推荐这种写法,IDE 也能直接指出哪个依赖没有装配。
5.6 统一处理异常
创建 GlobalExceptionHandler.java:
package com.susan.training.web;
import com.susan.training.common.ApiResponse;
import com.susan.training.order.OrderNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* REST 接口的统一异常出口。
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 订单不存在属于资源未找到,返回 HTTP 404。
*/
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> handleNotFound(OrderNotFoundException exception) {
return ApiResponse.failure("ORDER_NOT_FOUND", exception.getMessage());
}
/**
* Bean Validation 校验失败时返回 HTTP 400。
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException exception) {
// 本示例只返回第一个错误,生产项目可以扩展为完整错误列表。
String message = exception.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("请求参数不合法");
return ApiResponse.failure("INVALID_ARGUMENT", message);
}
}订单不存在返回 404,参数错误返回 400。HTTP 状态码用于协议层判断,code 用于业务错误分类,二者不要混在一起。
示例只取第一个校验错误,真实项目可以把所有字段错误放进 details 列表,并补充 traceId,方便从前端报错定位服务端日志。
第 6 步:添加配置文件
创建 demo-web/src/main/resources/application.yml:
spring:
application:
# 应用名会出现在日志、指标和监控数据中。
name: demo-platform
server:
# 本地开发端口;启动时可以用 --server.port 临时覆盖。
port: 8080
management:
endpoints:
web:
exposure:
# 只开放健康检查和基础信息。
include: health,infospring.application.name 会出现在日志和后续监控指标里。健康检查只暴露 health 和 info,不要把 Actuator 的所有端点直接开放到公网。
第 7 步:先写一个业务测试
在 demo-order/src/test/java/com/susan/training/order/OrderServiceTest.java 中创建:
package com.susan.training.order;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
class OrderServiceTest {
@Test
void createdOrderCanBeFoundByNumber() {
// 准备:业务模块是普通 Java 类,可以直接创建,不需要启动 Spring。
OrderService service = new OrderService();
// 执行:创建一笔金额为 99.00 的订单。
OrderView created = service.create(
new CreateOrderCommand("C1001", new BigDecimal("99.00"))
);
// 执行:使用返回的订单号再次查询。
OrderView found = service.findByOrderNo(created.orderNo());
// 断言:查询结果必须与创建时的业务数据一致。
assertThat(found.customerId()).isEqualTo("C1001");
assertThat(found.amount()).isEqualByComparingTo("99.00");
assertThat(found.status()).isEqualTo(OrderStatus.CREATED);
}
}这个测试不启动 Spring,也不需要数据库。它只验证一个行为:创建订单后,能够根据订单号查到同一条订单。
第一次写测试时不必追求覆盖率。先把最容易出错的业务行为固定下来,再逐步补边界条件,例如金额为空、订单号不存在和重复创建。
第 8 步:编译并启动
回到根目录执行:
mvn clean package第一次构建会下载依赖,耗时取决于网络和 Maven 镜像。成功时可以在 demo-web/target/ 下看到:
demo-web-1.0.0-SNAPSHOT.jar这个 JAR 已经包含 Web 模块和它依赖的业务模块,可以直接运行:
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar看到下面的关键日志,表示启动成功:
Tomcat started on port 8080 (http)
Started DemoApplication不要只看到 Spring 图标就认定启动完成。Tomcat 端口绑定失败、数据库连接失败、Bean 创建失败都可能发生在图标之后。
第 9 步:实际调用接口
9.1 创建订单
另开一个终端执行:
curl -i -X POST "http://localhost:8080/api/v1/orders" \
-H "Content-Type: application/json" \
-d '{"customerId":"C1001","amount":99.00}'预期返回 HTTP 201,响应体类似:
{
"code": "0",
"message": "success",
"data": {
"orderNo": "SO685E417C3C4F",
"customerId": "C1001",
"amount": 99.0,
"status": "CREATED"
},
"timestamp": "2026-11-06T01:59:50.000Z"
}你的 orderNo 和 timestamp 一定与示例不同,这是正常的。
9.2 查询刚才的订单
把响应里的订单号替换到下面:
curl "http://localhost:8080/api/v1/orders/SO685E417C3C4F"如果返回 code="0" 且订单信息一致,说明 Controller、业务服务和模块依赖已经全部打通。
9.3 检查参数校验
curl -i -X POST "http://localhost:8080/api/v1/orders" \
-H "Content-Type: application/json" \
-d '{"customerId":"","amount":0}'预期 HTTP 400:
{
"code": "INVALID_ARGUMENT",
"message": "客户编号不能为空",
"data": null
}9.4 检查 404
curl -i "http://localhost:8080/api/v1/orders/NOT-EXISTS"预期 HTTP 404,业务码为 ORDER_NOT_FOUND。
健康检查也可以直接访问:
curl "http://localhost:8080/actuator/health"预期:
{"groups":["liveness","readiness"],"status":"UP"}完成这四次请求,第一阶段的工程骨架就真正跑起来了。
为什么这几个报错最容易出现?
报错一:找不到 OrderService Bean
如果启动日志出现:
No qualifying bean of type 'com.susan.training.order.OrderService'先检查 OrderConfiguration 是否在 com.susan.training 的子包中,再确认启动类没有被放到 com.susan.training.web 这种过深的位置。
当前示例没有在 OrderService 上写 @Service,所以必须有 OrderConfiguration。如果删除了配置类,业务代码本身不会报编译错误,但 Spring 启动时无法注入依赖。
报错二:编译时找不到 org.springframework.stereotype
这说明业务模块使用了 Spring 注解,却没有引入 Spring 依赖。不要为了消掉错误,随手把整个 Web Starter 加到 demo-order。
先问一句:这个业务模块是否真的需要 Spring?如果只需要普通 Java 对象,像本文一样在 Web 模块写 @Bean 更干净;如果确实要用事务、缓存注解,再引入最小范围的 spring-context 或 spring-tx。
报错三:访问接口一直 404
依次检查:
- 请求路径是否为
/api/v1/orders。 OrderController是否在启动类扫描范围内。- 运行的是不是刚构建出来的
demo-webJAR。 @RequestMapping和@GetMapping是否拼写错误。
报错四:端口 8080 被占用
临时换一个端口:
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar --server.port=18080如果换端口后可以启动,问题就是端口占用,不是 Maven 或 Spring Boot 配置错误。
多模块项目适合所有项目吗?
不适合。
一个只有几个接口、没有独立复用需求的个人项目,单模块加清晰的包结构就足够了。提前拆五六个模块,配置和构建成本可能比收益更高。
出现下面这些信号时,再考虑拆模块:
- 业务代码需要被 Web、定时任务和消息消费者共同复用。
- 团队多人协作,单个模块经常互相覆盖。
- 某些代码需要独立发布、独立测试或独立接入其他系统。
- 构建时间已经明显变长,需要按模块增量构建。
模块不是越细越好。一个模块应该有明确的职责,而不是为每个 controller、service 都建一个 Maven 工程。
上线前还要补什么?
这篇用的还是教学版工程。真正接企业项目之前,至少要继续补上:
| 能力 | 当前示例 | 企业项目建议 |
|---|---|---|
| 数据持久化 | 内存 Map | MySQL/PostgreSQL + 数据库迁移 |
| 订单号 | UUID 片段 | 雪花算法或业务号段 |
| 参数校验 | 基础 Bean Validation | 业务规则和错误明细 |
| 异常处理 | 两个典型异常 | 统一错误码、traceId、告警 |
| 并发控制 | 内存容器 | 数据库锁、幂等键、事务 |
| 可观测性 | Actuator 健康检查 | 指标、日志、链路追踪 |
| 配置管理 | 单份 YAML | Profile、环境变量和配置中心 |
下一章可以把这里的 OrderService 换成 MyBatis 或 JPA 持久化。模块边界不变,只替换业务模块内部实现,这正是分层带来的价值。
常见问题
Spring Boot 4.0 必须使用 Java 21 吗?
本文项目明确设定 Java 21,并推荐使用 JDK 21 LTS。不要根据“父 POM 默认值”推断自己项目的运行环境,最终以项目里的 java.version 和 Maven 实际输出为准。
为什么不用 spring-boot-starter-web?
Spring Boot 4.0 拆出了 spring-boot-starter-webmvc。新项目直接使用更明确的 MVC Starter,减少对旧兼容入口的依赖。
demo-common 能放所有工具类吗?
不建议。公共模块应只放真正跨模块稳定复用的内容。把日期工具、HTTP 工具、业务常量都塞进来,会重新形成全局耦合。
接口返回为什么不用实体类?
数据库实体、业务对象和 HTTP 响应对象的职责不同。示例先拆出了请求对象和业务命令,后续接入数据库时也不要把 JPA 或 MyBatis 实体直接透传到接口层。
总结
这篇教程完成了 Spring Boot 4.0 多模块工程的完整闭环:根 POM 统一版本,demo-common 提供公共响应,demo-order 保存业务,demo-web 装配 Bean 并暴露 REST 接口。
真正需要记住的不是三个目录名,而是那条依赖方向:Web 依赖业务,业务依赖公共能力,任何下层模块都不反向依赖上层模块。
先运行起来,再逐步替换实现。下一阶段接数据库时,只修改 demo-order 的内部代码和 Web 模块的依赖配置,Controller 与公共响应仍然可以保持稳定。
上一节:企业级开发环境搭建
下一节:自动配置与自定义 Starter
相关阅读:
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
