当 “Clean Architecture” 并非如此干净:重新思考领域中的验证

发布: (2026年2月5日 GMT+8 23:12)
5 min read
原文: Dev.to

Source: Dev.to

一些时间前,我被要求维护一个据称基于 Clean Architecture 构建的 Java 应用。实际上……并没有那么干净。

几乎所有实体都落入了以下几类:

  • 夹杂着 Jakarta 注解 → 框架泄漏直接进入领域层
  • 塞满了 Guava Preconditions.checkArgument() → 侵入式的工具依赖
  • 被临时的空值检查污染 → 不一致、重复且很少被测试

我最终偶然发现了这样一个构造函数(已匿名化,但极其真实):

public Invoice(String customerEmail, BigDecimal amount, List items, LocalDate dueDate) {
    this.customerEmail = Preconditions.checkNotNull(customerEmail, "email required");
    Preconditions.checkArgument(customerEmail.contains("@"), "bad email");
    Preconditions.checkArgument(customerEmail.length()  0, "amount positive");

    this.items = Preconditions.checkNotNull(items);
    Preconditions.checkArgument(!items.isEmpty(), "items empty");
    Preconditions.checkArgument(items.stream().allMatch(Objects::nonNull), "null item");

    this.dueDate = Preconditions.checkNotNull(dueDate);
    Preconditions.checkArgument(dueDate.isAfter(LocalDate.now()), "future date");
}

它能工作,没错。
更糟的是:每一次失败都统一抛出 IllegalArgumentException,这使得在生产环境中根本无法区分是用户输入错误还是开发者的失误。

现有的验证尝试

我最初尝试了常见的几种方案:

  • Jakarta Bean Validation
  • Apache Commons Validate

这两者都有我无法接受的权衡:

  • 与外部框架的强耦合
  • 注解密集的 API,难以在复杂规则下进行组合
  • 通用异常会把所有验证失败都归为同一类

Bean Validation 3.0 加上 records 改善了一些情况,但仍然是基于注解的——而业务规则很少能很好地适配声明式注解。

面向领域的验证方法

如果我要在领域层引入任何依赖,它必须:

  • 没有传递依赖(单个 JAR,仅 java.base
  • 抛出类型化、意义明确的异常(而不是 IllegalArgumentException
  • 支持流式、可读的链式调用
  • 允许自定义谓词,而不把所有代码都写成 lambda 汤

同样的构造函数现在看起来是这样:

public Invoice(String email, BigDecimal amount, List items, LocalDate dueDate) {
    this.email = Assert.field("email", email)
                       .notBlank()
                       .email()
                       .maxLength(100)
                       .value();

    this.amount = Assert.field("amount", amount)
                        .positive()
                        .value();

    this.items = Assert.field("items", items)
                       .notEmpty()
                       .noNullElement()
                       .value();

    this.dueDate = Assert.field("dueDate", dueDate)
                         .inFuture()
                         .value();
}

相同的规则。
意图读起来像业务逻辑。

不再是通用的 IllegalArgumentException: bad email,而是类似下面的异常:

EmailFormatInvalidException {
  fieldName = "email",
  invalidValue = "...",
  constraint = "EMAIL_FORMAT"
}

好处

  • 清晰的监控 – 我们可以自动区分

    • 用户输入错误 → HTTP 400
    • 编程错误 → HTTP 500

    无需脆弱的字符串匹配,也不必猜测。

  • 显式的自定义谓词

    Assert.field("iban", iban)
          .notBlank()
          .satisfies(this::isValidIBANChecksum, "Checksum failed");
  • 无需注解 – 适用于避免 Hibernate‑first 模式的团队(在 JPA 实体上使用 @NotNull 价值不大)。

  • 无 AOP 魔法 – 有意在构造函数层面进行防御。

更大的图景

在这次经历之后,我看到验证库大致分为两类:

阵营特点
Framework validation (Jakarta, Spring)在 HTTP/绑定层表现出色;但对领域纯粹性糟糕
Utility validation (Guava, Apache Commons)可复用;在表达力和错误语义上较弱

缺少一种中间地带:domain‑native validation,它的写法像业务规则,失败时会抛出带类型的领域事件。这正是我试图填补的空白。

Open questions for the community

  • 在不污染领域模型的情况下,您如何处理验证?
  • 您是否在实体中接受 Jakarta 注解?
  • 您是否手动编写防御性代码?
  • 或者您已经找到完全不同的方法?

还有一个诚实的问题:类型化的验证异常(例如 NumberValueTooLowException)在生产环境中真的对您有帮助吗——还是说这只是过度工程?


项目:

想了解其他人是如何处理的。

Back to Blog

相关文章

阅读更多 »

垃圾回收深入解析 (JAVA)

您确定要隐藏此评论吗?它将在您的 post 中被隐藏,但仍可通过该评论的 permalink 查看。隐藏子评论……

dupl8

groovy // 1. 定义 “clean” JAR 的存放位置 def strippedLibDir = file'$buildDir/stripped-libs' // 2. 执行对 JAR 的 “手术” 的任务 task strip...