🏆本文收录于《滚雪球学SpringBoot 3.x》,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。
  
该专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。 如果想快速定位学习,可以看这篇【SpringBoot3教程导航帖】,你想学习的都被收集在内,快速投入学习!!两不误。
  
若还想学习更多,可直接订阅 《Spring Boot实战合集》,一次订阅,持续学习,后续更新内容无需重复付费,适合长期收藏与系统进阶。

演示环境说明:

  • 开发工具:IDEA 2021.3
  • JDK版本: JDK 17(推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 系列要求 Java 17,Spring Boot 3.5.4 基于 Spring Framework 6.x 和 Jakarta EE 9,它们都要求至少 JDK 17。)
  • Spring Boot版本:3.5.4(于25年7月24日发布)
  • Maven版本:3.8.2 (或更高)
  • Gradle:(如果使用 Gradle 构建工具的话):推荐使用 Gradle 7.5 或更高版本,确保与 JDK 17 兼容。
  • 操作系统:Windows 11

全文目录:

1. 为什么 Spring Boot 3.x 时代更需要做模块治理?

很多团队第一次接触 Spring Boot 时,都会被它“上手快”的特性吸引:一个启动类、几个 Controller、几个 Service、一个 Repository,项目就跑起来了。短期看,这种方式非常高效;但一旦系统进入稳定迭代阶段,问题也会迅速浮现:目录越来越乱、包越来越深、某个功能改动会牵一大片、测试越来越慢、编译越来越久、很多类彼此都能直接调用,最后谁也说不清边界在哪里。

到了 Spring Boot 3.x,这个问题比以前更值得重视。

第一,Spring Boot 3.x 把运行时和构建期的要求都抬高了。它以 Java 17 为基线,同时对 Jakarta 体系进行了全面切换,这意味着你在升级时,不能只看“代码能不能编译”,还要看“结构是否足够清晰、依赖是否足够健康、模块是否足够独立”。官方在 3.0 版本发布时就明确强调了 Java 17 基线、Jakarta EE 10 / EE 9 baseline、AOT 与原生镜像支持等变化。

第二,Spring Boot 的推荐开发方式本身就是“约定优于配置”。这句话很容易被误解成“少写配置就等于少做治理”。恰恰相反,约定优于配置的前提是团队必须形成统一约定:哪些类放在 API 层,哪些类属于领域层,哪些类只允许做适配,哪些模块可以依赖谁,哪些模块绝不允许反向依赖。Spring Boot 官方文档本身也把构建系统、代码结构、自动配置、依赖注入等内容放在开发最佳实践里,而不是只讲启动器。

第三,现代 Java 项目越来越强调构建效率。无论是 Maven 还是 Gradle,官方文档都把多模块、并行执行、代码复用、构建逻辑共享、增量构建当作核心能力来讲。Maven 官方明确说明,多模块项目不仅能做聚合,还能借助依赖管理保持可复现构建;Gradle 官方也直接把多项目构建、并行执行和代码复用作为组织大型工程的主要方式。

所以,这篇文章不只是教你“怎么拆模块”,更重要的是教你:怎么让模块拆得有意义,拆得有边界,拆得能持续演进。

2. 先把边界讲清楚:什么是 api / domain / app / infra

在很多项目里,模块名本身没有问题,问题在于名字背后没有责任定义。一个叫 common 的模块,最后常常装下了工具类、DTO、常量、枚举、第三方客户端、数据库实体、业务逻辑,甚至还会出现启动类。这样的模块并不是“公共模块”,而是“垃圾桶模块”。

我们今天采用的是一种更适合中大型 Spring Boot 项目的四层模块拆分方式:

2.1 api:对外契约层

api 负责描述系统对外暴露的“约定”,它通常包含:

  • 请求参数对象(Request / Command)
  • 响应结果对象(Response / DTO)
  • 对外开放的接口定义
  • 统一错误码、错误对象
  • Feign / RPC 的契约模型(如果有)

api 层的原则非常简单:只描述,不实现。

它不应该关心数据库,不应该关心事务,也不应该关心某个具体仓储怎么实现。它像一份合同,写清楚“你能找我做什么、我会返回什么”。

2.2 domain:领域核心层

domain 是整个系统最重要的模块,它负责承载业务本身。这里一般会放:

  • 聚合根、实体、值对象
  • 领域服务
  • 领域事件
  • 仓储接口
  • 领域异常
  • 业务规则与不变量校验

domain 关注的是“业务是什么”,而不是“怎么存、怎么传、怎么查”。

在一个健康的 Spring Boot 项目里,domain 应该尽量保持纯净,尽量少依赖 Spring 生态中的具体实现。它可以使用少量基础注解,也可以完全不依赖 Spring,从而让单元测试更加轻量,业务逻辑更加稳定。

2.3 app:应用编排层

app 是系统的入口和流程编排层,通常放:

  • 启动类 @SpringBootApplication
  • Web Controller
  • Application Service(应用服务)
  • 事务边界控制
  • 参数装配与结果组装
  • 调用 domaininfra 的协调代码

app 不是业务规则的所在,它的职责是“把业务流程串起来”。

简单地说,Controller 收到请求后,不应该直接堆满判断逻辑,而应当把请求转换成应用层命令,再由应用层协调领域对象完成业务动作。

2.4 infra:基础设施层

infra 负责和外部世界打交道,它通常包含:

  • Repository 实现
  • 数据库映射
  • Redis、MQ、ElasticSearch 等外部组件适配
  • 第三方 API 客户端
  • 文件存储、对象存储、短信服务实现

infra 是“怎么做”的世界。它可以依赖 Spring、JPA、MyBatis、Redis Client、HTTP Client,但它不应该反过来侵入领域层。

2.5 为什么要这样拆

这种拆法的核心,不是为了“看起来很架构”,而是为了把变化隔离开:

  • 业务规则变化,尽量只影响 domain
  • 接口协议变化,尽量只影响 api
  • 流程编排变化,尽量只影响 app
  • 技术实现变化,尽量只影响 infra

当变化被隔离之后,系统就不会出现“改一个接口,半个项目重编译”的情况。

3. 一张图看懂推荐的依赖方向

先看最核心的结构图。这里的重点不是“模块数量”,而是“依赖方向”。

这张图传达了两个非常重要的原则。

第一,领域层位于中心。 领域层不应该依赖控制器、不应该依赖数据库实现、不应该依赖某个具体外部系统。它只关心业务规则本身。

第二,应用层和基础设施层是外围适配。 它们可以依赖领域层,但领域层不要反向依赖它们。这样,即使你今天把 MyBatis 换成 JPA,或者把 Redis 换成数据库,领域层大概率都不需要改。

如果你只记住一句话,那就是:业务核心要稳定,技术实现要可替换。

4. 一个可落地的 Maven 多模块实践

下面我们用 Maven 来做一个完整的四模块工程。Maven 的优势在于结构清晰、生态成熟、依赖传递和版本管理稳定,适合很多传统 Java 团队。Maven 官方把多模块项目称为聚合工程,父 POM 可以聚合多个模块一起构建;同时,Maven 的依赖管理机制也非常适合大规模多模块工程的版本统一。

4.1 推荐目录结构

order-system/
├── pom.xml                  # 根聚合 POM
├── api/
│   ├── pom.xml
│   └── src/main/java/com/example/orders/api/...
├── domain/
│   ├── pom.xml
│   └── src/main/java/com/example/orders/domain/...
├── infra/
│   ├── pom.xml
│   └── src/main/java/com/example/orders/infra/...
└── app/
    ├── pom.xml
    └── src/main/java/com/example/orders/app/...

这个结构有两个好处。

一是清晰。每个模块都有自己的定位,不会把所有代码堆到一个项目里。

二是可控。你可以一眼看出谁依赖谁,也能一眼看出哪些模块可以单独测试、单独编译。

4.2 根 pom.xml

根 POM 的作用只有一个:统一管理版本、聚合模块、共享基础配置。 它本身不要承载业务代码。

<!-- 根 pom.xml -->
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>order-system</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <modules>
        <module>api</module>
        <module>domain</module>
        <module>infra</module>
        <module>app</module>
    </modules>

    <properties>
        <!-- 统一 Java 版本,Spring Boot 3.x 推荐 Java 17 起步 -->
        <java.version>17</java.version>
        <spring-boot.version>3.2.5</spring-boot.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- 统一管理 Spring Boot 相关依赖版本 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.13.0</version>
                    <configuration>
                        <source>${java.version}</source>
                        <target>${java.version}</target>
                        <encoding>UTF-8</encoding>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

这里要特别注意一件事:根工程最好是聚合 + 统一管理,不要直接塞业务实现。 很多团队把公共代码都堆在根工程,导致根工程既像一个公共库,又像一个业务模块,最后边界越来越模糊。

4.3 api 模块

api 只放契约,不放实现。

api/pom.xml
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>order-system</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>api</artifactId>

    <dependencies>
        <!-- 只引入校验 API,不要引入 Web、JPA 等重量级实现依赖 -->
        <dependency>
            <groupId>jakarta.validation</groupId>
            <artifactId>jakarta.validation-api</artifactId>
        </dependency>
    </dependencies>
</project>
OrderCreateRequest.java
package com.example.orders.api;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.math.BigDecimal;
import java.util.List;

/**
 * 下单请求对象
 * 说明:只描述外部契约,不承载业务规则实现。
 */
public class OrderCreateRequest {

    @NotBlank(message = "用户ID不能为空")
    private String userId;

    @NotEmpty(message = "订单项不能为空")
    private List<Item> items;

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public List<Item> getItems() {
        return items;
    }

    public void setItems(List<Item> items) {
        this.items = items;
    }

    public static class Item {
        @NotBlank(message = "商品ID不能为空")
        private String productId;

        @Min(value = 1, message = "购买数量必须大于0")
        private int quantity;

        private BigDecimal price;

        public String getProductId() {
            return productId;
        }

        public void setProductId(String productId) {
            this.productId = productId;
        }

        public int getQuantity() {
            return quantity;
        }

        public void setQuantity(int quantity) {
            this.quantity = quantity;
        }

        public BigDecimal getPrice() {
            return price;
        }

        public void setPrice(BigDecimal price) {
            this.price = price;
        }
    }
}
OrderDTO.java
package com.example.orders.api;

import java.math.BigDecimal;
import java.util.List;

/**
 * 订单返回对象
 */
public class OrderDTO {

    private String orderId;
    private String userId;
    private String status;
    private BigDecimal totalAmount;
    private List<OrderItemDTO> items;

    public String getOrderId() {
        return orderId;
    }

    public void setOrderId(String orderId) {
        this.orderId = orderId;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = status;
    }

    public BigDecimal getTotalAmount() {
        return totalAmount;
    }

    public void setTotalAmount(BigDecimal totalAmount) {
        this.totalAmount = totalAmount;
    }

    public List<OrderItemDTO> getItems() {
        return items;
    }

    public void setItems(List<OrderItemDTO> items) {
        this.items = items;
    }

    public static class OrderItemDTO {
        private String productId;
        private int quantity;
        private BigDecimal price;

        public String getProductId() {
            return productId;
        }

        public void setProductId(String productId) {
            this.productId = productId;
        }

        public int getQuantity() {
            return quantity;
        }

        public void setQuantity(int quantity) {
            this.quantity = quantity;
        }

        public BigDecimal getPrice() {
            return price;
        }

        public void setPrice(BigDecimal price) {
            this.price = price;
        }
    }
}

4.4 domain 模块

domain 模块是整个工程的核心,尽量少依赖 Spring。它可以定义仓储接口,但不要定义仓储实现。

domain/pom.xml
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>order-system</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>domain</artifactId>

    <dependencies>
        <!-- 领域层只依赖 api 中的契约对象,避免反向依赖 app/infra -->
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>api</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
    </dependencies>
</project>
OrderStatus.java
package com.example.orders.domain;

/**
 * 订单状态
 */
public enum OrderStatus {
    CREATED,
    PAID,
    CANCELLED
}
OrderItem.java
package com.example.orders.domain;

import java.math.BigDecimal;

/**
 * 订单项,属于领域对象
 */
public class OrderItem {

    private final String productId;
    private final int quantity;
    private final BigDecimal price;

    public OrderItem(String productId, int quantity, BigDecimal price) {
        if (productId == null || productId.isBlank()) {
            throw new IllegalArgumentException("商品ID不能为空");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("购买数量必须大于0");
        }
        if (price == null || price.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("商品价格不能为负数");
        }
        this.productId = productId;
        this.quantity = quantity;
        this.price = price;
    }

    public String getProductId() {
        return productId;
    }

    public int getQuantity() {
        return quantity;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public BigDecimal subtotal() {
        return price.multiply(BigDecimal.valueOf(quantity));
    }
}
Order.java
package com.example.orders.domain;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

/**
 * 订单聚合根
 */
public class Order {

    private final String id;
    private final String userId;
    private final List<OrderItem> items;
    private OrderStatus status;

    private Order(String id, String userId, List<OrderItem> items) {
        if (userId == null || userId.isBlank()) {
            throw new IllegalArgumentException("用户ID不能为空");
        }
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("订单项不能为空");
        }
        this.id = id;
        this.userId = userId;
        this.items = new ArrayList<>(items);
        this.status = OrderStatus.CREATED;
    }

    public static Order create(String userId, List<OrderItem> items) {
        return new Order(UUID.randomUUID().toString(), userId, items);
    }

    public String getId() {
        return id;
    }

    public String getUserId() {
        return userId;
    }

    public List<OrderItem> getItems() {
        return Collections.unmodifiableList(items);
    }

    public OrderStatus getStatus() {
        return status;
    }

    public BigDecimal totalAmount() {
        return items.stream()
                .map(OrderItem::subtotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    public void markPaid() {
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException("只有待支付订单才能支付");
        }
        this.status = OrderStatus.PAID;
    }

    public void cancel() {
        if (status == OrderStatus.PAID) {
            throw new IllegalStateException("已支付订单不能取消");
        }
        this.status = OrderStatus.CANCELLED;
    }
}
OrderRepository.java
package com.example.orders.domain;

import java.util.Optional;

/**
 * 领域层仓储接口
 * 说明:只定义能力,不关心具体存储方式。
 */
public interface OrderRepository {

    void save(Order order);

    Optional<Order> findById(String orderId);
}
OrderDomainService.java
package com.example.orders.domain;

import java.util.List;

/**
 * 领域服务:处理需要跨多个领域对象的业务规则
 */
public class OrderDomainService {

    public Order createOrder(String userId, List<OrderItem> items) {
        // 这里可以加入更复杂的业务校验,比如库存、限购、黑名单等
        return Order.create(userId, items);
    }
}

4.5 infra 模块

infra 模块只负责实现 domain 里定义的接口。

infra/pom.xml
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>order-system</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>infra</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>domain</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
    </dependencies>
</project>
InMemoryOrderRepository.java
package com.example.orders.infra;

import com.example.orders.domain.Order;
import com.example.orders.domain.OrderRepository;
import org.springframework.stereotype.Repository;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 内存版仓储实现
 * 说明:用于演示模块边界,方便本地直接运行,不依赖数据库。
 */
@Repository
public class InMemoryOrderRepository implements OrderRepository {

    private final Map<String, Order> storage = new ConcurrentHashMap<>();

    @Override
    public void save(Order order) {
        storage.put(order.getId(), order);
    }

    @Override
    public Optional<Order> findById(String orderId) {
        return Optional.ofNullable(storage.get(orderId));
    }
}

4.6 app 模块

app 模块承载 Spring Boot 启动、Web 接口和应用服务。

app/pom.xml
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>order-system</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>app</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>api</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>domain</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>infra</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
启动类 OrderApplication.java
package com.example.orders.app;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Spring Boot 启动类
 */
@SpringBootApplication(scanBasePackages = "com.example.orders")
public class OrderApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}
应用服务 OrderAppService.java
package com.example.orders.app;

import com.example.orders.api.OrderCreateRequest;
import com.example.orders.api.OrderDTO;
import com.example.orders.domain.Order;
import com.example.orders.domain.OrderDomainService;
import com.example.orders.domain.OrderItem;
import com.example.orders.domain.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

/**
 * 应用服务:负责流程编排,不写复杂业务规则
 */
@Service
public class OrderAppService {

    private final OrderRepository orderRepository;
    private final OrderDomainService orderDomainService;

    public OrderAppService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
        this.orderDomainService = new OrderDomainService();
    }

    @Transactional
    public OrderDTO createOrder(OrderCreateRequest request) {
        List<OrderItem> items = request.getItems().stream()
                .map(item -> new OrderItem(item.getProductId(), item.getQuantity(), item.getPrice()))
                .collect(Collectors.toList());

        Order order = orderDomainService.createOrder(request.getUserId(), items);
        orderRepository.save(order);

        return toDTO(order);
    }

    @Transactional(readOnly = true)
    public OrderDTO getOrder(String orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException("订单不存在: " + orderId));
        return toDTO(order);
    }

    private OrderDTO toDTO(Order order) {
        OrderDTO dto = new OrderDTO();
        dto.setOrderId(order.getId());
        dto.setUserId(order.getUserId());
        dto.setStatus(order.getStatus().name());
        dto.setTotalAmount(order.totalAmount());
        dto.setItems(order.getItems().stream().map(item -> {
            OrderDTO.OrderItemDTO itemDTO = new OrderDTO.OrderItemDTO();
            itemDTO.setProductId(item.getProductId());
            itemDTO.setQuantity(item.getQuantity());
            itemDTO.setPrice(item.getPrice());
            return itemDTO;
        }).collect(Collectors.toList()));
        return dto;
    }
}
控制器 OrderController.java
package com.example.orders.app;

import com.example.orders.api.OrderCreateRequest;
import com.example.orders.api.OrderDTO;
import jakarta.validation.Valid;
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.RestController;

/**
 * Web 层只做协议转换,不承载业务规则
 */
@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderAppService orderAppService;

    public OrderController(OrderAppService orderAppService) {
        this.orderAppService = orderAppService;
    }

    @PostMapping
    public OrderDTO create(@Valid @RequestBody OrderCreateRequest request) {
        return orderAppService.createOrder(request);
    }

    @GetMapping("/{orderId}")
    public OrderDTO detail(@PathVariable String orderId) {
        return orderAppService.getOrder(orderId);
    }
}

4.7 一个最小可运行的请求示例

启动应用后,可以用下面的 JSON 请求下单:

{
  "userId": "U10001",
  "items": [
    {
      "productId": "P1001",
      "quantity": 2,
      "price": 199.00
    },
    {
      "productId": "P1002",
      "quantity": 1,
      "price": 99.00
    }
  ]
}

这个请求会经历如下流程:

  1. Controller 接收并校验请求。
  2. 应用服务把请求对象转换为领域对象。
  3. 领域服务创建订单,领域对象计算金额并维护状态。
  4. 仓储接口由 infra 的内存实现保存。
  5. 应用服务把领域对象转换为返回 DTO。

这样一来,Web、业务、存储三者都被明确分开。

5. 一个可落地的 Gradle 多模块实践

如果你的团队更偏向 Gradle,那么多项目构建会非常适合模块化治理。Gradle 官方把多项目构建定义为一个根项目加多个子项目,并强调这种方式适合模块化、并行执行和代码复用;官方还建议通过合理拆分项目,使独立模块可以独立构建,从而减少不必要的整体重建。

Gradle 的特点是:

  • 配置灵活
  • 构建逻辑可以很优雅地抽到约定插件
  • 增量构建体验通常很好
  • 适合大型工程做构建治理

但 Gradle 的代价也很明显:如果团队没有统一约定,build 文件很容易失控。 所以,Gradle 项目更要强调“约定”和“模板化”。

5.1 推荐目录结构

order-system/
├── settings.gradle.kts
├── build.gradle.kts
├── api/
├── domain/
├── infra/
└── app/

5.2 settings.gradle.kts

rootProject.name = "order-system"
include("api", "domain", "infra", "app")

5.3 根 build.gradle.kts

plugins {
    id("org.springframework.boot") version "3.2.5" apply false
    id("io.spring.dependency-management") version "1.1.4" apply false
    java
}

allprojects {
    group = "com.example"
    version = "1.0.0-SNAPSHOT"
}

subprojects {
    apply(plugin = "java")
    apply(plugin = "io.spring.dependency-management")

    java {
        toolchain {
            languageVersion.set(JavaLanguageVersion.of(17))
        }
    }

    repositories {
        mavenCentral()
    }

    dependencies {
        add("testImplementation", platform("org.springframework.boot:spring-boot-dependencies:3.2.5"))
        add("testImplementation", "org.springframework.boot:spring-boot-starter-test")
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }
}

5.4 各子模块的依赖关系

api/build.gradle.kts

dependencies {
    implementation("jakarta.validation:jakarta.validation-api")
}

domain/build.gradle.kts

dependencies {
    implementation(project(":api"))
}

infra/build.gradle.kts

dependencies {
    implementation(project(":domain"))
}

app/build.gradle.kts

plugins {
    id("org.springframework.boot")
}

dependencies {
    implementation(project(":api"))
    implementation(project(":domain"))
    implementation(project(":infra"))
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

5.5 Gradle 的真正优势在哪里?

Gradle 最值得团队关注的,不是“写法更酷”,而是它对大工程的性能和组织能力更友好。你可以把公共构建逻辑抽成 convention plugin,把所有子模块统一套模板;你也可以开启并行构建、构建缓存和配置缓存,让构建在大项目里保持可接受的速度。

但要记住一点:构建工具只是放大器,不是救命药。 如果模块边界本身混乱,再强的构建工具也只能把混乱更快地执行完。

6. 如何彻底避免“全模块互相依赖”

“全模块互相依赖”通常不是某一个人故意写坏架构,而是团队在快速交付中不断妥协的结果。

一开始,某个功能想复用一段代码,于是把它挪到公共模块;后来另一个功能也要用,于是再加一点;再后来某个接口要调数据库,又顺手把 Repository 塞进公共模块;最后所有模块都开始依赖这个公共模块,甚至反向调用彼此的实现类。到了这个阶段,任何一个改动都像拆炸弹。

6.1 常见烂尾结构长什么样

common/
├── dto/
├── entity/
├── util/
├── service/
├── repository/
├── config/
└── controller/

这种结构的问题是:

  • entity 里混入数据库实体和领域实体
  • service 里既有业务逻辑又有流程编排
  • repository 里既有接口又有实现
  • controller 被别的模块直接调用
  • 所有东西都能被 common 引用,最后就变成“谁都能改,谁都不敢删”

6.2 用“禁止反向依赖”做第一道防线

最简单有效的原则就是:下层可以依赖上层的契约,上层不要依赖下层的实现。

更直白一点:

  • api 不依赖 app
  • domain 不依赖 app
  • domain 不依赖 infra
  • infra 只依赖 domain
  • app 可以依赖 apidomaininfra

只要这条线守住,大多数混乱就不会发生。

6.3 不要把“公共”当成“万能”

公共模块应该很少,而且要极其克制。适合放到公共模块里的,通常只有这几类:

  • 与业务无关的基础工具
  • 稳定且通用的错误码模型
  • 很薄的通用基础设施抽象
  • 极少量跨业务的基础类型

不适合放进去的东西更多:

  • 具体业务 DTO
  • 具体数据库表实体
  • 具体某个业务场景的服务实现
  • 只在一个模块里使用的枚举和常量

越“公共”的东西,越应该稳定;越不稳定的业务代码,越不应该塞到公共模块里。

6.4 用包名与模块名同时约束

只靠模块拆分还不够。你还要配合包名约束:

com.example.orders.api
com.example.orders.domain
com.example.orders.infra
com.example.orders.app

包名和模块名最好一一对应。这样做的价值在于:

  • 读代码时更容易理解归属
  • 配合检查工具时更容易写规则
  • PR 审查时更容易发现“错放”的类

6.5 用自动化检查守住边界

架构不是靠口头承诺维持的,而是靠工具约束的。你可以引入:

  • 架构测试
  • 依赖分析插件
  • 包结构检查
  • CI 里的模块依赖校验

例如,某些规则可以写成“domain 不允许引用 app 包名中的类”“api 不允许依赖 Spring Web 类型”“infra 不允许被 domain 直接引用具体实现”。这些规则一旦落地,架构就能持续稳定。

7. 构建速度优化:从“能跑”到“跑得快”

多模块拆分的目的之一,就是让构建更快。但如果拆完之后构建反而更慢,那说明你只做了形式上的分层,没有做真正的工程治理。

7.1 Maven 的提速思路

Maven 的提速,核心不是“加很多插件”,而是减少无意义的全量构建。

7.1.1 只构建受影响模块

当你只改了 domain 中的某个类,不要每次都从根目录做完全量重建。多模块项目本来就支持按模块构建,CI 里也应该尽量按变更范围触发。

7.1.2 父 POM 只做版本与规则

根 POM 不要引入一大堆业务依赖,更不要把业务 starter 全塞进去。父 POM 越干净,子模块越清晰,依赖解析也越容易。

7.1.3 减少无效传递依赖

很多构建慢,不是因为模块多,而是因为每个模块都在带一堆没必要的传递依赖。你可以检查:

  • 哪些依赖只在编译期用
  • 哪些依赖只在运行期用
  • 哪些依赖只是为了测试
  • 哪些依赖根本没用到

依赖越轻,构建越快,Classpath 越短,排查也越容易。

7.1.4 使用 Maven 的局部构建思路

在日常开发里,通常不需要每次都全量构建所有模块。对于改动较小的场景,按模块编译、按模块测试、按模块打包会更高效。大型系统里,这种开发习惯非常重要。

7.2 Gradle 的提速思路

Gradle 在多模块上通常更容易做速度优化,因为它天生就偏向增量和缓存。

7.2.1 并行执行

如果模块之间依赖关系清晰,Gradle 可以并行编译不相关的子项目。模块拆得越合理,并行收益越明显。

7.2.2 构建缓存

重复执行相同任务时,缓存能显著减少构建成本。尤其是 CI 场景,缓存命中率高的时候,速度差异非常明显。

7.2.3 配置缓存

构建配置阶段如果每次都重复扫描大量脚本,会非常慢。配置缓存能帮助减少这部分开销,但前提是你的构建脚本写得足够规范。

7.2.4 约定插件抽公共逻辑

不要把所有 subprojects {} 逻辑都堆在根构建脚本里。应该把公共约定抽成插件,减少重复配置。这样既能提升可维护性,也能减少构建脚本自身的复杂度。

7.3 影响构建速度的真实原因往往不是工具

很多团队总以为构建慢是 Maven 慢、Gradle 慢,其实常常不是。

真正拖慢构建的通常是这些问题:

  • 模块之间依赖过密
  • 公共模块过大
  • 测试代码和生产代码混在一起
  • 一个小改动导致大量模块重新编译
  • build 文件充满重复和动态逻辑
  • 集成测试环境搭建方式过重

所以,想让构建快,第一步不是换工具,而是让工程结构更“局部化”

7.4 最值得落地的三条优化建议

如果你今天就要开始优化,我建议先做这三件事:

  1. 删掉不必要的公共模块依赖
  2. 把只属于某个业务域的代码从 common 拆出去
  3. CI 改成按变更模块触发构建,而不是每次全量跑

这三件事往往比“再加一个构建插件”更有效。

8. 以订单系统为例,从接口到落库完整走一遍

理论讲完,我们再回到一个最常见的业务:订单创建。

8.1 业务流程

用户提交订单时,系统需要完成这些事:

  • 校验请求参数
  • 转换为领域对象
  • 执行业务规则
  • 生成订单编号
  • 保存订单
  • 返回结果

如果把这些事情都写进 Controller,代码会很快失控。正确方式是:

  • api 负责定义请求与响应
  • app 负责流程编排
  • domain 负责业务规则
  • infra 负责保存与适配

8.2 请求进入后的链路图

这条链路清楚地说明了谁应该负责什么。

8.3 为什么应用服务要存在

很多零基础同学一开始会问:为什么不能 Controller 里直接调领域对象,然后直接返回?

答案是:可以写,但不推荐。

因为 Controller 的职责应该是 HTTP 协议相关的事情:

  • 接收参数
  • 触发校验
  • 返回 HTTP 结果
  • 处理协议异常

而应用服务应该做:

  • 业务流程编排
  • 事务控制
  • 领域对象组合
  • 外部依赖协调

一旦把这两层混在一起,后续你想接入 MQ、补偿任务、分布式事务、异步处理、权限检查,就会非常难扩展。

8.4 领域层为什么要有自己的规则

假设有一个订单项数量是 0,Controller 层可以做校验,@Min(1) 也可以挡住一部分错误。但这并不意味着领域对象内部可以不做校验。因为领域层面对的并不一定只是 HTTP 请求,还可能是批处理、消息消费、定时任务、测试代码,甚至是别的应用服务构造出来的对象。

所以,外部校验只能减少错误,领域校验才能守住业务不变量。

这也是为什么 OrderItem 构造器里要校验数量和价格,Order 聚合里要校验状态流转。

8.5 一个简单的单元测试示例

为了保证领域规则稳定,我们应该优先测试领域层。

package com.example.orders.domain;

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

/**
 * 订单领域测试
 */
class OrderDomainServiceTest {

    @Test
    void should_create_order_and_calculate_total_amount() {
        OrderDomainService service = new OrderDomainService();
        Order order = service.createOrder(
                "U10001",
                List.of(
                        new OrderItem("P1001", 2, new BigDecimal("199.00")),
                        new OrderItem("P1002", 1, new BigDecimal("99.00"))
                )
        );

        assertEquals("U10001", order.getUserId());
        assertEquals(new BigDecimal("497.00"), order.totalAmount());
    }

    @Test
    void should_not_allow_zero_quantity() {
        assertThrows(IllegalArgumentException.class, () ->
                new OrderItem("P1001", 0, new BigDecimal("199.00"))
        );
    }
}

领域测试应该尽量轻、快、稳定。只要领域层写得干净,测试就会很好写;反过来,领域层一旦和 Spring、数据库、HTTP 强耦合,测试成本就会飙升。

9. 测试策略:模块内测、模块间测、集成测

模块治理做得好不好,最终要看测试是否容易组织。

9.1 模块内测试:优先测 domain

domain 的测试价值最大,因为这里装的是业务规则。你应该优先写:

  • 聚合创建测试
  • 状态流转测试
  • 值对象校验测试
  • 领域服务测试

这类测试不需要启动 Spring 容器,执行快、稳定性高。

9.2 app 层测试:测流程,不测规则

app 层测试的重点是流程编排,例如:

  • 接收请求后是否正确调用领域服务
  • 结果映射是否正确
  • 异常是否正确转换成接口错误

这里不应该重复测试领域规则,否则会造成测试重复。

9.3 infra 层测试:测适配,不测业务

infra 层测试应重点验证:

  • Repository 实现是否正确
  • 外部系统适配是否符合接口契约
  • 序列化、反序列化是否正确

如果用 JPA、Redis、MQ、HTTP Client,这些地方都应该有专门的适配测试。

9.4 集成测试:验证模块拼装是否正确

集成测试的目标不是覆盖所有分支,而是确认模块之间真正能组装起来:

  • app 能否注入 domaininfra
  • 启动类扫描范围是否正确
  • 配置是否完整
  • 接口调用链是否可用

多模块项目如果没有集成测试,最常见的问题就是:单模块都能跑,拼起来却启动失败。

9.5 不要把所有测试都写成 SpringBootTest

这是很多团队非常常见的浪费。

@SpringBootTest 很方便,但启动成本高。它适合少量关键集成测试,不适合把所有单元测试都改成全量启动。正确做法应该是:

  • 单元测试尽量不启动 Spring
  • 只有真正需要容器的场景才启动 Spring
  • 优先测试领域层,少测技术噪音

这样整个工程的测试速度才会稳。

10. 常见坑位与排查清单

多模块工程最容易踩坑的地方,通常不是语法,而是结构。

10.1 坑一:api 里放了实现类

如果 api 里出现 Controller、RepositoryImpl、ServiceImpl,那么这个模块就不再是契约层,而是混杂层。它会让其他模块误以为可以直接依赖实现,最终把边界彻底打穿。

解决方式: api 只保留 DTO、Command、Response、接口定义、错误模型。

10.2 坑二:domain 依赖 spring-webspring-data

一旦领域层开始依赖这些技术组件,领域层就失去纯粹性了。它会让业务对象和框架耦合,导致测试困难、迁移困难、复用困难。

解决方式: domain 只保留业务模型与接口,不直接依赖框架实现。

10.3 坑三:把“公共工具类”无限膨胀

公共工具类一旦没有边界,就会越长越大,最后变成新的“上帝模块”。

解决方式: 工具类可以有,但必须极少且稳定;业务复用应优先通过领域抽象来解决,而不是靠工具类硬拼。

10.4 坑四:一个模块既负责业务又负责存储

如果某个模块既有复杂业务逻辑,又直接操作数据库实体,那么它就是耦合点。后续任何改动都会牵动整个模块。

解决方式:domain 负责规则,让 infra 负责存储,让 app 负责编排。

10.5 坑五:模块之间直接调用彼此的 Controller

这属于非常危险的设计。Controller 是 HTTP 协议入口,不是给别的模块内部调用的业务 API。

解决方式: 模块间调用应该通过应用服务、领域服务或明确的接口契约,不要跨层直接调 Controller。

10.6 排查清单

每次你怀疑项目开始失控时,可以问自己以下问题:

  • 这个类的职责是不是单一的?
  • 它所在模块的名字,和它的真实职责一致吗?
  • 领域层是否依赖了技术实现?
  • 公共模块是不是塞了太多业务东西?
  • 改一个业务点,会不会引发大量无关模块重编译?
  • 测试是不是越来越依赖 Spring 容器?

如果答案大部分是“是”,那就说明你的模块治理已经开始退化了。

11. 你真正应该记住的治理原则

学多模块工程,不是为了“拆得更多”,而是为了“控制变化”。

原则一:模块是边界,不是目录美化

很多人把多模块理解成“把代码搬到不同文件夹里”。这其实不够。

真正的模块化,意味着:

  • 每个模块有独立责任
  • 每个模块依赖方向明确
  • 每个模块的变化范围可控
  • 每个模块都能被单独理解和验证

原则二:领域优先于技术

Spring Boot 提供了很强的自动配置、依赖注入和生态整合能力,但这些能力不应该掩盖业务本身。你先要把业务说清楚,再决定用什么技术来实现。

原则三:契约和实现分离

API、领域模型、基础设施实现,应该尽量分离。这样以后即使协议变了、存储变了、技术栈变了,也不会把整个系统拖下水。

原则四:构建工具服务于结构

Maven 和 Gradle 都很好,但它们解决的是“如何编译和组织构建”,不是“如何替你设计架构”。结构不好,工具越强,混乱传播越快。

原则五:越早治理,成本越低

等到系统已经写成一团,再重构模块,代价会非常高。最理想的做法,是在项目还不大时就把边界约定好,让团队从第一天就沿着正确方向写代码。

12. 附录:推荐的项目骨架与检查表

下面给出一个适合 Spring Boot 3.x 的推荐骨架,你可以直接拿去做初始模板。

12.1 推荐骨架

order-system/
├── pom.xml 或 build.gradle.kts
├── api/
│   ├── src/main/java/com/example/orders/api/
│   └── src/test/java
├── domain/
│   ├── src/main/java/com/example/orders/domain/
│   └── src/test/java
├── infra/
│   ├── src/main/java/com/example/orders/infra/
│   └── src/test/java
└── app/
    ├── src/main/java/com/example/orders/app/
    ├── src/main/resources/
    └── src/test/java

12.2 检查表

在提交代码前,你可以快速检查这些项:

  • api 是否只包含契约?
  • domain 是否只包含业务核心?
  • app 是否只做编排与入口?
  • infra 是否只做技术适配?
  • 有没有跨模块反向依赖?
  • 有没有把公共模块写成垃圾桶?
  • 有没有不必要的全量构建?
  • 单元测试是否优先覆盖 domain
  • Controller 是否被写成了业务巨石?
  • 有没有一处改动引发全局重编译?

12.3 最后再强调一次

多模块工程治理不是“文档工程”,而是“长期可维护性工程”。

当你的项目从单人开发变成多人协作,从一个功能不断扩展成一条业务线,从一次性项目变成长期演进系统时,模块边界会决定你的团队效率,也会决定你的技术债务速度。

Spring Boot 3.x 给了我们更现代的运行时基线,也给了我们更清晰的工程化背景。你越早把模块边界、依赖方向、构建效率和测试策略梳理清楚,后面的升级、扩容、重构、联调,就越从容。

真正成熟的工程,不是代码很多,而是每一层都知道自己该做什么,不该做什么。

结语

如果把这篇文章浓缩成一句话,那就是:

在 Spring Boot 3.x 时代,模块化不是可选项,而是系统长期健康的前提。

你只要把 api / domain / app / infra 的边界守住,把依赖方向守住,把构建和测试的责任拆开,项目就会越来越清晰;反过来,只要一开始图省事,把所有东西混在一起,后面就会花更多时间为混乱买单。

这也是为什么,真正优秀的 Java 工程师,不只是会写业务代码,更会设计工程边界。

ok,同学们,本节课就上到这儿,下课~

🧧 学习福利 · 限时开放 🧧

当然,无论你是计算机专业在读学生,还是对编程充满兴趣的入门者,都强烈建议系统学习SpringBoot全体系专栏:👉 「滚雪球学 Spring Boot」;涵盖SpringBoot所有教学内容。

该专栏以“循序渐进 + 实战驱动”为核心理念,从基础到进阶到就业到架构师逐层展开,帮助你快速建立完整的 Spring Boot 技术体系,带你玩转SpringBoot框架。

📌 学习承诺:
通过该专栏,你将能够:

  • 快速掌握 Spring Boot 核心开发能力
  • 构建完整的后端项目认知体系
  • 实现从“入门”到“独立开发”的跃迁

就像“滚雪球”一样,知识不断积累、能力持续放大,实现指数级成长 🚀

最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。

同时欢迎大家关注技术号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G PDF编程电子书、简历模板、技术文章Markdown文档等海量资料。

ps:本文涉及所有源代码,均已上传至Gitee开源,供同学们直接对照学习 Gitee传送门,同时,原创开源不易,欢迎给个star🌟,想体验下被🌟的感jio,非常感谢❗

🫵 Who am I?

我是 bug菌:

更多高质量技术内容及成长资料,可查看这个合集入口 👉 点击查看 👈️

硬核技术号 「猿圈奇妙屋」 期待你的加入,一起进阶、一起打怪升级。

- End -

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐