从领域原语构建的正确领域模型会迫使我们开发人员做正确的事情。
让我们想出一个简单的 Java 方法签名示例:
String foo(String str);
这段代码有什么作用?签名只告诉我们它是一个方法:它对输入字符串执行某些操作,或者它可能无法执行某些操作并返回null,或改变某些状态,或执行其他副作用,或抛出一个Throwable。
现在让我们看一下 Scala 中的等效签名:
现在让我们看一下 Scala 中的等效签名:
sealed trait PostalCode |
更好的是,此代码将一个String转换为两种可能PostalCodes(美国或德国)之一,否则会返回None. 函数是尝试从字符串创建有效的邮政编码。我们只需要用几种类型指定我们的域,现在这段代码的意图很明确。
借用领域驱动设计中的一个术语,领域是专用于构建领域原语的那些业务。
原始语言(String,boolean,int,float等方式)是一种编程代码的构建块;而领域原语是领域模型和业务逻辑的构建块(ShoppingBag,Order,Quantity等业务方式)。
领域原语的定义是精确的,不可能是非法的,它的存在意味着它的有效性。
当在实际业务逻辑中使用这些域原语时,以这种方式建模的真正力量就会显现出来:
def x(p: PostalCode, n: HouseNumber): Future[Option[Address]]
此代码只能做一件事:根据邮政编码和门牌号异步返回地址或不返回结果。此功能适用于任何人,PostalCode因此我们可以推断每个域都支持美国和德国地址。
而你能从以下方法明白地搞清楚这些吗?
CompletionStage<String> x(String s, int i);
语言原语和炸弹处理
让我们用另一个例子来研究在领域逻辑中使用语言原语的潜在问题,这次使用正确的命名,我们在其中建模一种从一个账户向另一个账户转账的方法:
boolean transfer(String fromAccountNumber, String toAccountNumber, BigDecimal amount);
现在让我们计算一下该方法可能被错误调用的方式:
- 任何论点都可以null。
- amount 可以为零,这在转账时没有意义。
- amount 可以是负面的,这也没有意义。
- fromAccountNumber或者toAccountNumber可能不是实际帐号。
- fromAccountNumber并且toAccountNumber可以是相同的帐号,这又没有意义。
- fromAccountNumber并且toAccountNumber可以混淆,向错误的方向转移资金。
这是这种方法的 9 个潜在错误,编译器不会帮助我们解决其中任何一个。
类型别名
相比之下,请考虑使用优秀的scala-newtype库的以下 Scala 代码:
@newtype case class FromAccount(a: Account) |
我们像以前一样添加了额外的类型信息,现在如果调用者混淆了参数,我们的代码将无法编译。我们一直向上移动缺陷层次结构,很好!
上面的代码比较冗长,但谁在乎呢?代码是类型安全的,其意图更加明确。
许多现代高级语言支持一些类型别名和/或新类型的概念:
- Scala 2(如前所述)具有scala-newtype
- Scala 3 有不透明的类型别名
- Haxe 支持抽象原语
- Haskell内置了newtype
- Kotlin 有类型别名
- Swift 支持类型别名声明
- OCaml 具有自定义数据类型
Java 什么都没有,所以我们必须自己创建包装器。
如何发现弱模型
这是一个不完整的需要注意的事项清单,可能需要进行一些炸弹处理:
- 检查nulls,应使用Option类型对缺席进行建模。
- 具有两个或多个相同类型参数的函数(除非您的函数是可交换的)。
- 在使用Set或OrderedSet更合适地方使用List
- 语言原语作为业务逻辑的方法输入。
- UUIDs 作为方法参数,客户 uuid 与订单 uuid 不同。
- 当需要至少一个元素时,使用 List而不是NonEmptyList。