本节目标:把卷一那个
library单模块拆成五个边界清楚的 Maven 模块,并把依赖方向写进 POM,让mvn package成为拆分是否成立的第一个判据。
适用版本:JDK 25.0.5(Temurin)、Maven 3.9.12、JUnit 5.14.4。
承接的问题
卷一结束时的 library 只有一个 pom.xml,源码目录里同时躺着领域 record、内存仓储、CLI 解析器和手写 HTTP 服务。十八篇文章各写一层,合起来能跑,但所有类共享一个编译类路径:CommandParser 可以随手 import 仓储实现,Book 也能引用 com.sun.net.httpserver。卷一只有标准库,这种混放最多算不整洁;卷二要接 PostgreSQL、Javalin 和 Testcontainers,问题会立刻变成工程问题。
具体有三类代价:
- 编译期没有边界。 任何类都能看见任何依赖,领域代码误用
java.sql.Connection时,编译器不会阻止。 - 测试期没有分层。 一个纯
Loan单元测试也会把 Javalin、HikariCP、Testcontainers 全部拉进测试类路径,启动变慢,失败原因也变多。 - 变更影响面不可见。 改一个 JSON 字段要全量重编;想把领域 model 抽出来给另一个服务复用,得先手工挑文件。
拆模块不是为了目录好看,而是把「谁能依赖谁」从口头约定变成 Maven 的构建图。构建图一旦写错,Maven 会直接拒绝,而不是等到 code review。
拆分的依据:变化频率与依赖方向
先定依赖方向,再定目录。libraryd 的五个模块按下面的方向单向依赖:
libraryd-integration-tests
|
v
libraryd-app -----> libraryd-web ---------> libraryd-domain
| ^
+-------> libraryd-persistence --------+
domain 在图的底部,不依赖任何模块;persistence 和 web 都只依赖 domain;app 是唯一同时看见三者的地方;integration-tests 只依赖 app,从外部把服务当作黑盒。这个形状有两个直接收益:领域规则可以脱离数据库和 HTTP 单独测试;装配知识集中在 app,换 Web 框架或换持久化实现时不需要动领域代码。
五个模块的职责与允许依赖如下:
| 模块 | 负责什么 | 允许依赖 | 明确不允许 |
|---|---|---|---|
libraryd-domain | Book、Member、Loan、错误码、仓储端口、罚金规则 | 仅 JDK,测试期 JUnit | JDBC、Javalin、Jackson、日志实现 |
libraryd-persistence | JDBC 实现、连接池、Flyway 迁移 | domain、PostgreSQL 驱动、HikariCP、Flyway | web、app |
libraryd-web | Javalin 路由、DTO、参数校验、错误映射 | domain、Javalin、Jackson、Validation | persistence、app |
libraryd-app | AppMain、配置读取、对象装配、可执行包 | 全部业务模块、Logback、Micrometer | 不写业务规则 |
libraryd-integration-tests | Testcontainers 端到端测试 | app、JUnit、Testcontainers | 直接依赖 persistence |
把业务服务放在 domain 而不是 web,是这张表里最容易被质疑的一条。LoanService 需要仓储端口,但端口是 domain 自己定义的接口,实现由 persistence 提供;服务只依赖接口,因此仍然可以留在 domain。如果把它放进 web,app 就必须依赖 web 才能拿到服务,persistence 也会被迫依赖 web,依赖图立刻出现环。
拆到五块就停,不再继续切 DTO 模块或异常模块。判断标准不是「概念上能不能再分」,而是「是否有人独立复用、是否有不同的运行时依赖、是否独立变化」。DTO 和异常都跟着 web/domain 一起变,单独成模块只会增加 POM 数量。
父 POM 与五个子模块
父 POM 只做三件事:声明 reactor 里有哪几个模块、集中版本、提供插件配置。它自己 packaging 为 pom,不产出 jar。
<groupId>com.plume</groupId>
<artifactId>libraryd-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>libraryd-domain</module>
<module>libraryd-persistence</module>
<module>libraryd-web</module>
<module>libraryd-app</module>
<module>libraryd-integration-tests</module>
</modules>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.plume</groupId>
<artifactId>libraryd-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.plume</groupId>
<artifactId>libraryd-persistence</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.plume</groupId>
<artifactId>libraryd-web</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.plume</groupId>
<artifactId>libraryd-app</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
${project.version} 在父 POM 的 dependencyManagement 里指向 1.0.0-SNAPSHOT,子模块引用内部构件时不写版本。这一步是后面 1.2 依赖治理的地基:版本只有一个来源。
domain 的 POM 短到几乎没有内容,这正是它应有的样子:
<parent>
<groupId>com.plume</groupId>
<artifactId>libraryd-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>libraryd-domain</artifactId>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
persistence 多出基础设施依赖,并且 PostgreSQL 驱动用 runtime 作用域:
<artifactId>libraryd-persistence</artifactId>
<dependencies>
<dependency>
<groupId>com.plume</groupId>
<artifactId>libraryd-domain</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
</dependencies>
app 依赖三个业务模块,web 依赖 domain 与 Javalin/Jackson,integration-tests 只依赖 app 和测试库。完整片段见附录 A,本节只强调方向:没有任何子模块反向依赖 app。app 是叶子,不是中心。
在 libraryd 里落地:最小骨架
拆完之后目录形状如下,包根统一为 com.plume.library:
libraryd/
├── pom.xml
├── libraryd-domain/
│ └── src/main/java/com/plume/library/domain/
│ ├── Book.java
│ ├── Member.java
│ ├── Loan.java
│ ├── ErrorCode.java
│ ├── LibraryException.java
│ ├── FinePolicy.java
│ └── BookRepository.java
├── libraryd-persistence/
│ └── src/main/java/com/plume/library/persistence/
│ └── DatabaseConfig.java
├── libraryd-web/
│ └── src/main/java/com/plume/library/web/
│ └── HttpConfig.java
├── libraryd-app/
│ └── src/main/java/com/plume/library/AppMain.java
└── libraryd-integration-tests/
└── src/test/java/com/plume/library/it/
└── LibrarydSmokeTest.java
领域类型先落最小的三个 record,字段与卷二契约一致。Book 只做自身校验,不引用任何基础设施:
package com.plume.library.domain;
public record Book(String isbn, String title, String author, int year) {
private static final int MIN_YEAR = 1000;
private static final int MAX_YEAR = 2100;
public Book {
if (isbn == null || isbn.isBlank()) {
throw new IllegalArgumentException("isbn 不能为空");
}
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("title 不能为空");
}
if (author == null || author.isBlank()) {
throw new IllegalArgumentException("author 不能为空");
}
if (year < MIN_YEAR || year > MAX_YEAR) {
throw new IllegalArgumentException("year 超出范围: " + year);
}
}
}
AppMain 放在 app 模块,包名是 com.plume.library,这样它既能看见 persistence 的 DatabaseConfig,也能看见 web 的 HttpConfig:
package com.plume.library;
import com.plume.library.persistence.DatabaseConfig;
import com.plume.library.web.HttpConfig;
public final class AppMain {
private AppMain() {
}
public static void main(String[] args) {
DatabaseConfig database = DatabaseConfig.fromEnvironment();
HttpConfig http = HttpConfig.fromEnvironment();
System.out.printf(
"libraryd skeleton: db=%s pool=%d http=%d%n",
database.url(), database.poolSize(), http.port());
}
}
第 1 章不启动 Javalin,也不连数据库,只验证装配关系成立:app 能同时引用两个下层模块,而 domain 对它们一无所知。DatabaseConfig 只读取 LIBRARYD_DB_URL、LIBRARYD_DB_USER、LIBRARYD_DB_PASSWORD、LIBRARYD_POOL_SIZE,第 3 章才用它创建连接池。
本机工具链先确认:
$ java -version
openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment (Temurin-25.0.5+8) (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode, sharing)
$ mvn -version
Apache Maven 3.9.12 (848fbb4f2d427b72bdb2471c22fced7eb9a7a1)
Maven home: /Users/<you>/apache-maven-3.9.12
Java version: 25.0.5, vendor: Eclipse Adoptium, runtime: /Users/<you>/.jdks/jdk-25.0.5+8/Contents/Home
路径做了脱敏,版本行是原样输出。接着构建:
$ mvn -DskipTests package
[INFO] Reactor Summary for libraryd-parent 1.0.0-SNAPSHOT:
[INFO] libraryd-parent .................................... SUCCESS [ 0.480 s]
[INFO] libraryd-domain .................................... SUCCESS [ 2.165 s]
[INFO] libraryd-persistence ............................... SUCCESS [ 0.506 s]
[INFO] libraryd-web ....................................... SUCCESS [ 1.074 s]
[INFO] libraryd-app ....................................... SUCCESS [ 0.687 s]
[INFO] libraryd-integration-tests ......................... SUCCESS [ 0.677 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 5.726 s
这是本机输出。六行 reactor 是父 POM 加五个子模块;父 POM 显示为 libraryd-parent,子模块按 <modules> 顺序构建。第一次在新机器上跑会久得多,因为要下载 Javalin 7.2.4、JUnit 5.14.4 等依赖。
构建顺序提示:如果按本节顺序第一次执行
mvn package,很可能在libraryd-web或libraryd-app处被 Enforcer 的dependencyConvergence拦下。原因是 Javalin 7.2.4 与 Jedis 8.0.2 的传递依赖里各有一处版本分歧。1.2 会给出真实的报错和两处收敛配置;在本节先确认模块解析与依赖方向正确即可。
常见错误与边界
把「分层」做成「互相依赖」。 例如让 domain 依赖 web 只为复用一个 PageResponse,Maven 会直接报环:
[ERROR] The projects in the reactor contain a cyclic reference:
Edge between 'Vertex{label='com.plume:libraryd-web:1.0.0-SNAPSHOT'}' and
'Vertex{label='com.plume:libraryd-domain:1.0.0-SNAPSHOT'}' introduces to cycle
in the graph com.plume:libraryd-domain:1.0.0-SNAPSHOT -->
com.plume:libraryd-web:1.0.0-SNAPSHOT -->
com.plume:libraryd-domain:1.0.0-SNAPSHOT
这是本机把 domain 临时加上 web 依赖后复现的输出。分页模型应该留在 web,或者下沉成 domain 自己的类型,不能反向引用。
让 domain 依赖 Jackson。 常见理由是「给 record 加 @JsonProperty 省一个 DTO」。一旦 domain 引入 Jackson,领域模块就被序列化框架绑死,卷三想换序列化方式时要改领域类型。正确做法是第 6 章在 web 里建 DTO,domain 保持只认 JDK 类型。
让 integration-tests 直接依赖 persistence。 这样测试可以绕过 app 的装配,单独 new 一个仓储实现。测试看似方便,实际上验证的是「类能连上」,不是「服务能启动」。集成测试模块应只依赖 app,通过 HTTP 或 AppMain 暴露的入口驱动。
把 app 当成公共模块。 只要有人写 app -> web 之外的边,例如 web -> app 取配置,app 就不再是叶子,装配代码会被下层反向引用。判断方法很简单:在 mvn dependency:tree 里,app 只应出现在 integration-tests 的下面。
小结
- 拆模块的第一依据是依赖方向,第二依据才是职责;
domain在底部,app在顶部,integration-tests只从外部看服务。 - 五个模块分别解决领域规则、持久化、HTTP、装配和端到端测试,暂不继续拆 DTO 或异常模块。
- 父 POM 负责模块列表、版本与插件配置,子模块引用内部构件不写版本。
- PostgreSQL 驱动用
runtime作用域,编译期只依赖java.sql,避免代码误用驱动私有类。 - 模块环会被 Maven 在 reactor 阶段直接拒绝,这比 code review 更早、更确定。
mvn package的六行 reactor 输出是拆分成立的第一份证据。
模块边界定下来后,下一步是把散落在各处的版本号和依赖作用域收紧。下一节处理 Javalin 与 Jedis 带来的真实版本冲突。
阅读导航:上一节:《Java 编程实战》目录 · 下一节:1.2 依赖治理:BOM、作用域与版本冲突 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。