《Java 编程实战》1.1 从单模块到多模块:libraryd 的拆分边界

把卷一的单模块 library 拆成 domain、persistence、web、app、integration-tests 五个模块:先按变化频率和依赖方向划边界,再写出可编译的父 POM、子模块 POM 与最小骨架,最后用 Maven reactor 和依赖树验证拆分结果。

本节目标:把卷一那个 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-domainBook、Member、Loan、错误码、仓储端口、罚金规则仅 JDK,测试期 JUnitJDBC、Javalin、Jackson、日志实现
libraryd-persistenceJDBC 实现、连接池、Flyway 迁移domain、PostgreSQL 驱动、HikariCP、Flywayweb、app
libraryd-webJavalin 路由、DTO、参数校验、错误映射domain、Javalin、Jackson、Validationpersistence、app
libraryd-appAppMain、配置读取、对象装配、可执行包全部业务模块、Logback、Micrometer不写业务规则
libraryd-integration-testsTestcontainers 端到端测试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、作用域与版本冲突 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发