首页/文章/Java / Spring
Java / Spring

Spring Boot 项目模块化架构

用业务模块边界、依赖规则和领域事件控制单体项目的复杂度,并以 Spring Modulith 验证架构约束。

Spring Boot 单体项目变难维护,通常不是因为部署单元只有一个,而是因为任何包都能访问任何包、业务规则散落在多个 service、一次小改动就要理解整个应用。模块化单体仍然作为一个进程部署,但明确业务边界、公开 API 和允许依赖;它是一种控制复杂度的工程手段,也为未来拆分服务留下更可靠的边界。

从技术分层转向业务模块

按 controller/ service/ repository/ 做全局分层,初期清楚,业务增长后容易出现每个业务都要改全局包的情况。可以先按业务能力分模块,再在模块内部采用领域、应用、基础设施分层。

flowchart LR
  web[HTTP Adapter] --> order[订单模块]
  order -->|公开 API / 命令| payment[支付模块]
  order -->|OrderPlaced 领域事件| inventory[库存模块]
  inventory --> db1[(库存数据)]
  order --> db2[(订单数据)]
  payment --> db3[(支付数据)]
  order -.禁止依赖内部实现.-> inventoryInternal[库存 internal 包]

模块间有两种常见协作方式:同步公开 API 适合调用方必须立即得到结果的查询/命令;领域事件适合“订单已创建,其他模块可订阅”这类一个事实、多个响应者的流程。事件让生产者无需知道所有消费者,但它不会自动给你分布式事务或消息可靠性。

目录表达边界

com.example.shop
├── ShopApplication.java
├── order
│   ├── package-info.java
│   ├── OrderManagement.java       # 对外应用 API
│   ├── OrderPlaced.java           # 公开事件
│   └── internal
│       ├── OrderRepository.java
│       └── OrderEntity.java
├── inventory
│   ├── package-info.java
│   ├── InventoryReservation.java  # 公开 API
│   └── internal
│       └── ReservationRepository.java
└── payment
    ├── PaymentGateway.java
    └── internal

internal 命名本身不会阻止 Java 代码访问;边界要靠模块验证、代码评审和测试执行。不要让 Web controller 直接访问别的模块 repository,也不要通过共享数据库 entity 暗中耦合两个模块的生命周期。

用 Spring Modulith 验证模块依赖

Spring Modulith 从应用主包下面的直接子包识别 application modules。给模块声明允许依赖,并把依赖校验加进测试:

// package com.example.shop.order;
@org.springframework.modulith.ApplicationModule(
        allowedDependencies = {"inventory", "payment"}
)
package com.example.shop.order;
package com.example.shop;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;

class ModularityTests {
    @Test
    void moduleDependenciesAreValid() {
        ApplicationModules.of(ShopApplication.class).verify();
    }
}

验证可以发现模块依赖环、访问其他模块内部包,以及超出允许列表的依赖。将它放进 CI 后,新的违规依赖会在合并前失败,而不是等到代码库已经充满循环依赖后才开始治理。

用事件连接模块

订单模块在领域状态变更后发布事件。消费者只依赖事件契约,不直接读取订单模块内部实现:

public record OrderPlaced(UUID orderId, UUID customerId, List<Line> lines) {
    public record Line(String sku, int quantity) {}
}

@Service
class OrderManagement {
    private final OrderRepository orders;
    private final ApplicationEventPublisher events;

    @Transactional
    public UUID place(PlaceOrder command) {
        Order order = Order.create(command);
        orders.save(order);
        events.publishEvent(new OrderPlaced(
                order.id(), order.customerId(), order.lines()));
        return order.id();
    }
}

@Component
class ReserveInventoryOnOrderPlaced {
    private final InventoryReservation inventory;

    @ApplicationModuleListener
    void on(OrderPlaced event) {
        inventory.reserve(event.orderId(), event.lines());
    }
}

模块事件是进程内通信,仍然要明确事务边界:消费者失败是否应回滚订单事务?事务提交后进程崩溃是否允许漏处理?若事件需要可靠重放,可以评估 Spring Modulith Event Publication Registry,或采用显式 Transactional Outbox + 消息代理。不要仅凭 @Async 就宣称事件“可靠异步”。

模块化带来的测试形状

  • 模块结构测试:校验禁止依赖、模块环路和内部包访问。
  • 模块集成测试:围绕一个业务模块启动必要的 Spring slice,验证公开 API 和本模块持久化。
  • 事件契约测试:验证事件字段语义、消费者幂等和失败后的重试策略。
  • 端到端流程测试:只覆盖少量关键流程,避免全部逻辑都依赖一个庞大的全应用测试。

模块划分不该细到每个实体一个模块。可以以“谁拥有规则和数据、谁有独立变更节奏、谁需要独立扩缩容”为线索,结合团队边界和业务语言迭代。稳定 API 比包名更能表达长期边界。

常见踩坑

  1. 把模块化等同于微服务:模块化单体没有网络 hop,但也共享进程、部署和故障域;不需要为了划模块马上拆服务。
  2. 只建 package,不加约束:结构会被快捷引用逐渐侵蚀。把 ApplicationModules.verify() 加入 CI,并审查模块 API 的变更。
  3. 共享 entity 或 repository:这让消费者绕过拥有者的业务规则。跨模块传公开 DTO/命令/事件,而非 JPA entity。
  4. 事件滥用:同一个事务里需要立即返回结果的查询,事件会隐藏依赖并让控制流难追踪。选择同步 API 或事件要看业务语义。
  5. 把内存事件误当可靠消息:进程内发布不等于 durable。对必须交付的后续动作,使用 publication registry/outbox,并定义重试、幂等和死信处理。
  6. 模块切分只按技术层:common 最后可能成为所有业务的依赖垃圾桶。共享内容应小而稳定,避免把业务决策藏进通用 util。

参考资料

文章反馈

如果这篇文章对你有帮助,欢迎发邮件交流。

通过邮箱联系 ↗继续阅读 →