一、模块化单体是什么

很多团队一说到系统改造,脑海里就只有两条路:要么抱着老的单体系统继续熬,要么狠下心拆成微服务。实际上,中间还有一条很温和的路,叫模块化单体。它本质上还是一个程序,打包成一个文件部署,但是内部代码按照业务切成了一个个独立的模块,比如商品模块、订单模块、用户模块。每个模块自己管自己的事,模块之间通过明确的小接口打交道,而不是像以前那样谁想改一个字段就直接翻到别人代码里去。

生活里就像一栋合租的大房子,几个人共用一套水电和门禁,但每个人有自己独立的房间和锁。你在自己房间里怎么折腾都行,但要进别人房间必须敲门。微服务则是每个人都搬出去住,串门还得开车。模块化单体省钱省事,又比以前那种完全敞开的大通铺强得多。

1.1 模块化单体的应用场景

哪些项目适合用模块化单体?最典型的是业务边界比较清楚的中小型系统。比如一个电商后台,商品、订单、用户、库存这四个板块就像四个抽屉,彼此有联系但划分明确。这种系统如果一上来就拆微服务,团队得去摆平服务发现、分布式事务、消息队列等一连串问题,成本立刻上来。

还有一种场景是存量系统改造。一个老单体代码已经堆了十几万行,直接重写风险太大。这时候可以先把代码按业务重新组织,让模块边界一点点浮出来,后续真有必要再拆微服务,也能顺藤摸瓜。

1.2 技术优缺点

优点很直观。第一,简单,因为还是一个部署进程,没有网络开销,开发和调试体验接近单体。第二,模块边界能抑制代码腐化,至少不会像完全的单体那样到处互相引用。第三,保留未来拆分微服务的可能性,到时候只需要把某个模块的接口改成HTTP调用就行。

缺点也不能忽略。模块边界是靠团队纪律维护的,如果没人坚持,就会悄悄退回大泥球。另外,所有模块仍然共享一个进程,所以某个模块内存泄漏或死循环会拖垮整个应用。如果你的团队自控力一般,但又想享受模块化的收益,架构评审和自动化测试就得跟上。


二、评审模块设计合理性的几个核心维度

架构评审不是走过场,而是把模块设计摆上台面,用几个固定问题反复“过筛子”。下面这五个维度,是我在项目里比较常用的一套检查方法。

2.1 模块边界是否按业务切分

评审时第一个问题:这个模块的划分,是按照业务能力来的,还是按照技术分层来的?很多团队会把 controller、service、mapper 各抽一个模块,那不叫模块化单体,那叫“按代码层次分包”。一个模块应该能完整地表达一块业务,比如商品模块同时包含商品的接口、领域逻辑、数据操作,只是把这些内部实现藏在里面,对外只露出一个门面。

下面用一个简单的商品模块来演示。在这个例子里,商品模块对外只暴露 DTO 和 Service 接口,内部实现对外完全不可见。

// 技术栈:Java 17 + Spring Boot 3.2
// 路径: catalog/src/main/java/com/example/modules/catalog/api/ProductDto.java
package com.example.modules.catalog.api;

import java.math.BigDecimal;

/**
 * 商品信息对象,作为商品模块对外输出的数据格式。
 * 其他模块只认这个形状,不关心商品表内部字段。
 *
 * @param id    商品id
 * @param name  商品名称
 * @param price 商品价格
 */
public record ProductDto(
        Long id,
        String name,
        BigDecimal price
) {
}
// 技术栈:Java 17 + Spring Boot 3.2
// 路径: catalog/src/main/java/com/example/modules/catalog/api/ProductService.java
package com.example.modules.catalog.api;

import java.util.Optional;

/**
 * 商品模块对外暴露的唯一服务入口。
 * 实现类藏在 internal 包里,调用方只能通过这个接口操作。
 */
public interface ProductService {

    /**
     * 按商品id查询商品数据。
     *
     * @param productId 商品id
     * @return 商品信息,不存在时返回 Optional.empty()
     */
    Optional<ProductDto> findById(Long productId);
}

注意看,这个模块的边界就是 api 包。别的模块依赖商品模块,只能依赖 api 的内容。评审时一定要打开代码看看,是不是所有对外调用都在走 api 包,如果出现直接访问 internal 包,那模块边界已经破洞了。

2.2 依赖方向是否单向

模块之间最怕循环依赖。怎么讲?订单模块要查商品信息,于是依赖了商品模块;反过来商品模块如果又依赖订单模块查销量,两个模块就拧成了麻花。评审时最简单的办法是画一张模块级依赖图,规则只有一个:不能成环。

下面用订单模块的价格计算器来展示什么叫“单向依赖”。订单模块依赖商品模块的接口,但商品模块绝不反向依赖订单。

// 技术栈:Java 17 + Spring Boot 3.2
// 路径: order/src/main/java/com/example/modules/order/api/OrderLine.java
package com.example.modules.order.api;

import java.math.BigDecimal;

/**
 * 订单行条目,表示“某商品买了几件”。
 * 订单模块只保存商品的 id,不持有商品模块内部类。
 *
 * @param productId 商品id
 * @param quantity  数量
 */
public record OrderLine(
        Long productId,
        BigDecimal quantity
) {
}
// 技术栈:Java 17 + Spring Boot 3.2
// 路径: order/src/main/java/com/example/modules/order/internal/OrderPriceCalculator.java
package com.example.modules.order.internal;

import com.example.modules.catalog.api.ProductDto;
import com.example.modules.catalog.api.ProductService;
import com.example.modules.order.api.OrderLine;
import org.springframework.stereotype.Service;

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

/**
 * 订单模块内部的价格计算器。
 * 它通过 ProductService 接口去查价格,而不是偷偷操作商品表。
 * 依赖方向:订单 -> 商品,清晰而且单向。
 */
@Service
public class OrderPriceCalculator {

    private final ProductService productService;

    public OrderPriceCalculator(ProductService productService) {
        this.productService = productService;
    }

    /**
     * 计算一组订单行的总金额。
     * 如果某个商品查不到,就直接抛出异常,让上层去处理。
     */
    public BigDecimal calculateTotal(List<OrderLine> lines) {
        BigDecimal total = BigDecimal.ZERO;
        for (OrderLine line : lines) {
            ProductDto product = productService.findById(line.productId())
                    .orElseThrow(() -> new IllegalArgumentException("商品不存在,id=" + line.productId()));
            total = total.add(product.price().multiply(line.quantity()));
        }
        return total;
    }
}

评审时看到“订单模块直接依赖商品模块接口”,这是合理的。但如果看到“订单模块直接 import 商品模块的 private 实现类”,或者反过来“商品模块依赖订单模块”,就要打回去。

2.3 模块能不能独立替换

什么叫独立替换?简单说,你想要改一个模块的实现,只要接口不变,就可以整个模块重写,而不影响其他模块。所以评审时要检查模块内部实现是否被外部引用。如果外部代码直接 new 了一个实现类,那么这个模块根本没法替换。

比较好的做法是模块内部实现类用 Spring 的依赖注入来管理。外部拿到的是接口,哪天你把实现类整个换了,只要接口没变,别人无感知。下面是一个商品模块实现类的例子,它遵守了“对外只暴露接口”的约定。

// 技术栈:Java 17 + Spring Boot 3.2
// 路径: catalog/src/main/java/com/example/modules/catalog/internal/ProductServiceImpl.java
package com.example.modules.catalog.internal;

import com.example.modules.catalog.api.ProductDto;
import com.example.modules.catalog.api.ProductService;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.Map;
import java.util.Optional;

/**
 * ProductService 的实现类。
 * 这个类故意放在 internal 包里,让外面摸不到。
 */
@Service
public class ProductServiceImpl implements ProductService {

    /**
     * 演示用内存存储,实际项目里这里会调用数据库。
     */
    private final Map<Long, ProductDto> productStore = Map.of(
            1L, new ProductDto(1L, "机械键盘", new BigDecimal("499.00")),
            2L, new ProductDto(2L, "曲面显示器", new BigDecimal("1599.00"))
    );

    @Override
    public Optional<ProductDto> findById(Long productId) {
        return Optional.ofNullable(productStore.get(productId));
    }
}

2.4 共享模块是否又变成了垃圾桶

模块化单体几乎都会有一个 shared 或 common 模块。这个模块的危险程度很高,因为大家会把不顺手的东西都扔进去。评审时盯紧一点:共享模块里只能放“没有业务含义”的工具类、全局结果封装、统一异常,不能放业务实体和业务规则。

下面是一个共享模块的通用返回结构,它不依赖任何业务模块。

// 技术栈:Java 17 + Spring Boot 3.2
// 路径: shared/src/main/java/com/example/modules/shared/api/ApiResult.java
package com.example.modules.shared.api;

/**
 * 统一的接口返回对象。
 * 这个类放在 shared 里,是因为它不含任何业务。
 */
public class ApiResult<T> {

    private final boolean success;
    private final String message;
    private final T data;

    private ApiResult(boolean success, String message, T data) {
        this.success = success;
        this.message = message;
        this.data = data;
    }

    /** 成功返回 */
    public static <T> ApiResult<T> ok(T data) {
        return new ApiResult<>(true, "ok", data);
    }

    /** 失败返回 */
    public static <T> ApiResult<T> error(String message) {
        return new ApiResult<>(false, message, null);
    }

    public boolean isSuccess() {
        return success;
    }

    public String getMessage() {
        return message;
    }

    public T getData() {
        return data;
    }
}

评审时可以问两个问题:第一,业务模块能不能不依赖 shared?如果几乎每个模块都得依赖它,那 shared 必须足够“瘦”。第二,shared 里有没有出现“订单”“商品”这样的词?如果有,说明业务逻辑又开始混进来了。

2.5 模块对外接口是否小而稳

模块对外接口越少、越稳定,以后内部调整就越自由。如果一个模块把内部所有类都导出给别人,那跟没有边界也差不多。在 Java 9+ 中,可以用模块声明文件控制哪些包可以对外。

// 技术栈:Java 17 模块化系统(JPMS)
// 路径: order/src/main/java/module-info.java

module com.example.modules.order {
    // 订单模块需要读取商品模块的接口
    requires com.example.modules.catalog;

    // 只允许别人访问订单模块的 api 包
    exports com.example.modules.order.api;

    // internal 包不写 exports,外部自然访问不到
}

也许你用的是 Spring Boot,不一定会启用 JPMS。这个例子想说的是一个道理:模块的可见性要主动控制,而不是靠大家自觉。

评审时也容易忽视“事件”这层边界。模块之间除了同步调用,还可以通过发布事件进行异步协作。比如订单模块创建订单后,发布一个“订单已创建”的事件,商品模块订阅它去减库存。这样依赖方向依然保持单项,而且模块间的耦合更低。不过事件会带来消息丢失、重复消费等问题,评审时要掂量清楚模块化单体的承受能力。


三、保障模块设计合理性的落地手段

评审靠人眼,总会有看漏的时候。更靠谱的办法是把规则变成自动化测试,让机器替我们把门。

3.1 用 ArchUnit 守护模块依赖

ArchUnit 是一个写架构规则的测试库。你可以把“订单不能依赖用户”这种规则直接写成一个 JUnit 测试,跑 CI 的时候自动执行。下面这个例子展示了如何检查两个模块之间有没有依赖,以及模块之间有没有循环依赖。

// 技术栈:Java 17 + JUnit 5 + ArchUnit 1.2.0
// 路径: src/test/java/com/example/modules/ArchitectureTest.java
package com.example.modules;

import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.Test;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

/**
 * 架构守护测试。
 * 这些规则不仅仅是为了评审,更是为了平时每一次提交都守住边界。
 */
class ArchitectureTest {

    @Test
    void orderShouldNotDependOnUser() {
        JavaClasses importedClasses = new ClassFileImporter()
                .importPackages("com.example.modules");

        ArchRule rule = noClasses()
                .that().resideInAPackage("..order..")
                .should().dependOnClassesThat()
                .resideInAPackage("..user..");

        rule.check(importedClasses);
    }

    @Test
    void noCyclicDependenciesBetweenModules() {
        JavaClasses importedClasses = new ClassFileImporter()
                .importPackages("com.example.modules");

        com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices()
                .matching("com.example.modules.(*)..")
                .should().beFreeOfCycles()
                .check(importedClasses);
    }
}

注意:SlicesRuleDefinition.slices() 会把 com.example.modules 下的第一层包当成一个切片,比如 catalog、order、user。如果 A 切片依赖 B,B 又依赖 A,规则就会报错。

3.2 用 Spring Modulith 做模块化体检

Spring Modulith 是 Spring 社区专门为模块化单体提供的一个工具,它能自动扫描 Spring 组件,识别模块边界,验证依赖是否违规,还能生成模块文档。和 ArchUnit 相比,它更懂 Spring 的套路,比如能识别@Service、@Repository 这些注解。

使用方式非常简单。先引入依赖,然后写一个测试。

<!-- 技术栈:Java 17 + Spring Boot 3.2 + Spring Modulith 1.2.0 -->
<!-- 路径: pom.xml 中的依赖片段 -->
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-core</artifactId>
    <version>1.2.0</version>
</dependency>

接着用启动类和一个测试类来验证模块结构。

// 技术栈:Java 17 + Spring Boot 3.2 + Spring Modulith 1.2.0
// 路径: src/main/java/com/example/modules/ModularityApplication.java
package com.example.modules;

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

/**
 * 模块化单体应用的启动类。
 * Spring Modulith 会从启动类所在的根包开始扫描。
 */
@SpringBootApplication
public class ModularityApplication {

    public static void main(String[] args) {
        SpringApplication.run(ModularityApplication.class, args);
    }
}
// 技术栈:Java 17 + Spring Boot 3.2 + Spring Modulith 1.2.0
// 路径: src/test/java/com/example/modules/ModularityTests.java
package com.example.modules;

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

/**
 * 使用 Spring Modulith 做模块化校验。
 */
class ModularityTests {

    @Test
    void verifyModuleStructure() {
        // 扫描所有 Spring 组件,自动识别模块边界
        ApplicationModules modules = ApplicationModules.of(ModularityApplication.class);

        // 校验模块依赖是否合规,比如是否有循环依赖
        modules.verify();
    }
}

Spring Modulith 还会从模块中读取@ApplicationModule 注解来定义显式边界,适合在评审之后把边界变成一个长期生效的“合同”。

3.3 评审检查清单

自动化工具不能完全替代人,所以你可以在架构评审会上带一份清单:

  • 模块命名是否直接表达业务,而不是技术层?
  • 模块对外是否只有一个 api 包?内部实现是否没有被外部引用?
  • 模块依赖图有没有环?
  • shared 模块有没有混入业务逻辑?
  • 数据库表是否被多个模块直接访问?
  • 模块内的公共类、工具类有没有越变越大?

这些问题只要有一个不过关,模块设计就不能算“合理”。


四、常见坑和注意事项

模块化单体最大的坑,就是把“模块化”做成了“分层”。表面上有包名隔开,实际上 controller 包里的代码直接调用另一个模块 internal 包里的类,边界形同虚设。还有一种常见问题,是模块依赖通过数据库偷偷耦合在一起:订单模块和商品模块各有一套表,但为了查方便,直接去 join 对方的表。这种情况代码层面很难发现,必须靠数据库权限设计来辅助。

再来就是过度设计。有团队为了模块化,把几行代码的工具类都拆成一个模块,光模块就建了二十几个。拆多了以后,模块之间疯狂互相引用,评审会直接从“边界是否清晰”变成“这模块到底该不该存在”。记住,模块化单体不是拿来表演拆分的,而是为了控制复杂度。模块数量保持在个位数到十几个以内,比较合理。

另外要注意,模块化单体最后还是单体。它没有消除单点故障的可能性,也没有解决部署体积大的问题。如果未来真的有某个模块需要快速扩容,到时候再把它单独拆成微服务也不迟。所以评审时不必为了微服务提前引入一堆分布式组件,增加无谓的复杂度。


五、总结

模块化单体给单体系统打开了一扇窗户:它不让推倒重来,但允许你一点点重建秩序。架构评审的核心,就是盯着模块边界、依赖方向、共享代码和对外接口这些容易被糊弄过去的地方。用 ArchUnit 或 Spring Modulith 把规则变成自动测试,比靠人肉开会更有说服力。

最后记住一个朴素的原则:模块是给人重新算账的边界,不是给电脑看的装饰。切得合理,维护起来舒服;切得随意,到了后期就是另一座大山。