分层、模块化与依赖方向:代码边界怎样阻止耦合扩散
创建订单的代码会经过入口、业务处理和数据库访问。把它们放进 controller、service、repository 三个目录后,仍需决定:业务代码能否引用 JDBC 类,订单模块能否直接读取库存模块的内部对象,两个模块互相调用时由谁负责协调。
分层规定代码的职责与允许依赖,模块把可以一起理解和修改的功能组织起来。包名帮助表达这些安排,编译路径、可见性和架构测试负责检查实际代码是否遵守它们。
包、模块、层和进程各组织什么
四种常用的组织单位
| 单位 | 管理的对象 | 实际约束 |
|---|---|---|
| Java package | 类型命名与包级访问 | package-private 成员只对同包代码可见;子包是独立包 |
| Maven module | 一个可独立构建的项目及其制品 | 根据声明和传递依赖形成编译 classpath |
| JPMS 命名模块 | 模块可读性、导出包与反射开放 | requires、exports、opens 分别控制不同访问 |
| 服务进程 | 运行、资源与故障处理单元 | 调用跨越网络或 IPC,增加部署和运行处理 |
com.example.orders.internal 在普通 classpath 项目里仍只是包名。外部代码只要看得到 JAR,并且类型是 public,就可能引用其中的类型。反过来,包级私有类型也不会因为调用方属于 com.example.orders 的子包就自动可见。JLS 包与模块
Maven 的 <modules> 聚合构建顺序,<dependencies> 决定项目使用哪些依赖。将三个目录列为模块,并不会自动禁止其中任意两者相互引用;还需要管理实际依赖声明。dependencyManagement 提供版本管理,也不会自动将依赖加入每个模块。Maven 多模块构建
模块可以全部装进一个服务,也可以分别形成库或独立服务。先按当前维护需求选择隔离强度,拆进程时再处理远程失败、部署版本和数据一致性,见模块化单体。
技术分层与业务分包
按技术分层时,同类职责放在一起:
application/
├── controller/ OrderController、InventoryController
├── service/ OrderService、InventoryService
└── repository/ OrderRepository、InventoryRepository小应用中,这种结构容易浏览,也便于统一框架接入。订单和库存变大后,一次订单修改可能需要在多个大目录之间切换;不同业务还容易通过共享 service 或 repository 互相引用。
按业务分包,可以将同一业务的代码先放在一起:
application/
├── orders/
│ ├── api/
│ ├── application/
│ ├── domain/
│ └── adapter/
└── inventory/
├── api/
└── internal/每个业务模块内部再选择需要的层次。一个只提供简单配置查询的模块可以很小,不必机械生成 Controller、Service、Manager、Repository、DAO 五套空转类。目录数量应随着真实职责增加,而不是随模板增加。
判断模块是否合适,可以追踪一种具体变化:修改订单折扣,需要同时改哪些文件和公开接口?如果修改总是穿过三个模块,可能是一个用例缺少上层协调,也可能是业务被切得过碎。先找变化原因,再调整目录。
层与模块的公开面
应用层表达用例,例如“创建订单”“取消订单”;领域代码维护金额、状态转换等业务规则;适配层处理 JDBC、HTTP 或外部 SDK。组合根负责创建对象并连接实现,通常位于启动模块或框架配置中。
模块公开 API 应提供调用方需要的能力和数据。让调用方拿到内部 JdbcTemplate、ORM 查询对象或供应商 SDK 类型,会把这些实现选择变成外部依赖。公开一个小的查询结果或命令对象,往往更容易维持双方独立修改。
DTO、领域对象和数据库行是否需要分别定义,取决于它们的用途、变化频率和转换要求,具体见数据模型与防腐层。
调用数据库,为什么源码可以依赖接口
运行调用与源码依赖
应用服务需要保存订单,但可以通过自己定义的端口表达这项需求:
public interface OrderRepository {
void save(Order order);
}JDBC 实现引用这个接口并实现它。启动时把实现传给应用服务:
源码依赖
application → domain
adapter → application 的 OrderRepository
adapter → JDBC / 数据库驱动
运行调用
CreateOrder → OrderRepository 引用 → JdbcOrders 实例 → 数据库运行时仍然由业务调用数据库;源码中,应用服务只认识保存订单所需的接口。更换 JDBC 实现时,只要接口约定与实际行为保持,应用服务无需跟着导入新框架。这是依赖倒置在代码中的一种实现方式。
接口并非越多越好。给每个工具类机械加一个同名接口,只增加文件而没有隔离变化。适合建立端口的位置,通常是业务需要稳定表达、但外部实现或测试替代会变化的地方,例如存储、时钟、付款和通知。
谁定义接口,谁装配实现
接口应描述使用方需要的操作,不照搬第三方全部 API。loadOrder(id)、save(order) 比返回任意 Query 对象更容易限制调用范围。底层错误怎样转换、空结果怎样表示、是否加入当前事务,也应在接口及其实现中保持一致。
在 Spring 中,可以通过 @Bean 或组件扫描注册实现,再注入应用服务。依赖注入负责装配对象,依赖倒置负责代码之间的方向;即使完全手工 new,也能保持相同方向。
配置和框架注解是否进入应用层,需要按项目选择。一些项目接受应用服务使用 @Transactional;需要纯 Java 应用层时,也可以将事务执行交给外部适配器。只要团队明确允许哪些依赖,并把它们体现在测试中,就不必把所有项目归为同一种分层形式。Spring 事务注解说明了代理调用等实际限制。
循环通常怎样出现
订单模块调用库存模块预留库存,库存模块再调用订单模块确认业务状态,就形成了双向依赖。三个模块也可能沿更长的路径成环。
orders → inventory → promotion → orders环中的公开类型或初始化方式变化,可能要求几个模块一起调整。Maven 项目依赖中的循环会影响 reactor 构建;单个 JAR 里的包循环通常仍能编译,需要另外检查。Spring Bean 的循环引用与这两种循环又有不同的触发条件,不能混用一个“循环依赖”结论。
可按实际原因处理:
- 将跨模块流程放进上层应用服务,由它顺序调用两个模块。
- 由使用方定义稳定端口,让实现依赖端口。
- 对已经完成的事实发布事件,让接收方独立反应;同时处理失败、重复和顺序。
- 若两个模块始终共享同一组规则和修改节奏,合并可能更直接。
把两边都需要的类型随手移进 common 只能改变图形。这个类型仍由谁修改、调用是否仍相互等待,都需要重新确认。
用编译产物验证实际依赖
下载并运行多模块工程
环境为 Linux amd64、Bash、Docker Engine、unzip。使用有 Docker 权限的普通用户,解压目录需要可写。下载 工程质量实验 后,在新目录解压:
LAB=$(mktemp -d -t quality24.XXXXXX)
unzip quality-engineering-lab.zip -d "$LAB"
cd "$LAB/quality-engineering"
docker version
bash run-docker.sh -Dtest=ArchitectureTest -Dsurefire.failIfNoSpecifiedTests=false
test -f verification/target/surefire-reports/example.quality.verification.ArchitectureTest.txt || exit 1构建镜像为 maven:3.9.12-eclipse-temurin-17,使用宿主 UID/GID,Maven 缓存写入 .m2。run-docker.sh 先 clean,再编译所有模块和指定测试。无该测试类的上游模块允许跳过,但最后必须存在 verification 的测试报告,不能把全工程没有执行测试当成成功。
quality-engineering
├── domain Order、Money,没有 Spring 或 JDBC 依赖
├── application 创建用例与存储/事务端口,依赖 domain
├── adapter JDBC、Spring 事务、配置、对象转换
└── verification 使用上述制品执行真实规则与行为测试ArchUnit 固定 1.5.0,读取编译后的 class 文件。它能观察字段类型、方法签名、调用等字节码关系,而不是只统计源码里的 import 行。ArchUnit User Guide
正常依赖与两种违规
ArchitectureTest 的第一项导入生产类并排除测试夹具,检查领域层不依赖应用层、适配层或 Spring;应用层不依赖适配层;业务包之间没有环。导入集合还需非空,防止扫描错目录后没有检查对象。
核心规则采用真实 ArchUnit API:
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..application..", "..adapter..", "org.springframework..")
.check(classes);第二项导入故意写错且实际编译的测试夹具:BadDomain 有一个 JdbcOrders 类型的字段。第三项导入 ComponentA、ComponentB,它们的字段互相引用,形成包循环。两个负例都必须被规则拒绝,并在诊断里出现具体类型。
ArchitectureTest
productionModulesFollowDependencyDirection 通过
actualReverseDependencyIsRejected 捕获 BadDomain → JdbcOrders
actualClassCycleIsRejected 捕获 ComponentA ↔ ComponentB正常汇总为 3 项测试通过。这里后两项通过,表示测试捕获了预期违规,违规夹具只存在于测试源码,未加入生产模块。
让违规真正中断构建,再恢复
以下命令让反向依赖的规则异常直接抛出,预期非零退出。保存输出后检查失败原因:
if bash run-docker.sh -Dtest=ArchitectureTest \
-Dsurefire.failIfNoSpecifiedTests=false -Dquality.arch.negative=true \
> architecture-negative.log 2>&1; then
echo '预期违规未中断构建' >&2
exit 1
fi
grep -q 'BadDomain' architecture-negative.log || exit 1
grep -q 'JdbcOrders' architecture-negative.log || exit 1日志应同时指向来源类型和被引用类型。镜像下载失败或编译工具找不到,不应被解释为架构规则生效。恢复时去掉负例开关,重新执行前面的正常命令,报告应再次为 3 项通过。
切换 Java 运行环境时,可以设置:
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25 \
bash run-docker.sh -Dtest=ArchitectureTest -Dsurefire.failIfNoSpecifiedTests=false编译目标仍为 Java 17。当前工程将规则放在独立 verification 模块,因而可以一次读取多个模块的产物;把架构测试放在某个看不到其他模块的测试 classpath 中,会漏掉相应依赖。
结构调整时怎样减少连带修改
为已有工程逐步建立约束
先选择一条与当前维护问题直接相关的规则,例如“领域金额类型不引用 Web 请求对象”或“订单模块不访问库存内部包”。确认违规指向真实代码,再决定立即修复还是保留有期限的历史例外。
迁移过程中可以先移动装配代码,再抽出稳定端口,最后修改调用方。每一步都让现有业务测试继续运行。大量历史违规同时出现时,应逐项区分真问题、规则范围错误和已知暂缓项,避免一次建立覆盖整个项目的忽略名单。
允许列表应绑定具体规则和类型。新增违规不能因为旧违规总数减少就被掩盖;已有问题修复后,例外也应随之删除。静态工具与历史问题的处理见依赖检查与技术债。
字节码规则看不到什么
ArchUnit 主要观察导入的编译产物。反射字符串、动态 SQL、运行配置和网络调用背后的业务关系,可能不表现为普通类型依赖;生成代码是否参与检查,也需要明确。规则通过后,仍需测试真实配置、数据访问和模块交互。
在命名模块中,exports 开放包中的可访问类型,opens 用于深反射。框架需要反射时,尽量向具体模块开放必需包,并验证运行配置;不要因一次反射失败就将全部模块无条件开放。项目采用 classpath 时,也不应宣称已经获得 JPMS 的强封装。
源码依赖限制可以减少误用,进程权限、网络策略和数据库账号才负责限制运行时资源访问。类没有引用某个包,并不能阻止它通过允许的网络或文件 API 访问外部资源。
检查修改后的实际效果
| 现象 | 可能原因 | 下一步 |
|---|---|---|
| 规则突然全部通过,但导入类很少 | 扫描路径或测试 classpath 变了 | 核对导入数量和代表性类型 |
| 移到 common 后仍需联合修改 | 共同类型的含义或修改责任没有分开 | 缩小共享内容或重新划分模块 |
| 单元测试容易,接数据库却大量失败 | 端口约定漏了事务、查询或错误语义 | 加真实适配器测试并修正约定 |
| 拆模块后构建变慢且导航更困难 | 模块粒度过细,组合成本增加 | 合并共同变化的单元,减少空转层 |
| 去掉依赖后运行期才失败 | 反射、SPI 或序列化仍需要该依赖 | 查实际加载路径,保留必要运行依赖 |
一次有效的结构调整,应让具体变更少碰不相关代码,也让错误更容易定位。保留业务行为测试与依赖检查,两者一起比较重构前后,才能判断目录调整是否真正改善了维护。
权威资料与规范地址
包、模块与依赖规则
- Java 包与命名模块:https://docs.oracle.com/javase/specs/jls/se17/html/jls-7.html
- Maven 多模块构建:https://maven.apache.org/guides/mini/guide-multiple-modules.html
- ArchUnit:https://www.archunit.org/userguide/html/000_Index.html
