战术DDD:在Postgres中存储值对象方法

领域驱动设计 (DDD) 是一种将软件建模为与业务领域紧密结合的方法。
  • 战略设计:涉及定义限定上下文,例如将电子商务商店拆分为订单、库存和客户管理等领域),在更高层次上运作
  • 战术设计:使用技术资源构建领域模型时可以应用战术设计模式,从而帮助丰富领域模型。

那么,什么是值对象,为什么我们应该使用它们?
领域驱动设计中的战术设计带来了清晰度。例如,像 Money 这样的值对象:它应该是 BigDecimal、Double 还是自定义的 Money 类?

  • - 使用BigDecimal或Double可能会导致错误。
  • - Money 类确保计算精确,并包括货币和金额的验证。
DDD 有助于创建现实世界概念的有意义且准确的表示。
  • 值对象没有概念身份并且是不可变的,所以我们只能在给定的上下文中推断它们。
  • 它们还为您提供了额外的类型安全性,使验证类型变得更加容易。
  • 对于熟悉值对象的人来说,它使代码库更容易理解——至少对我来说是这样。代码的整个结构反映了您正在建模的域,您可以清楚地看到这一点。
  • 另一方面,您最终会得到更多的类和更高的开发工作量,因此考虑权衡很重要。
对于一个简单的 CRUD 应用程序,我不会费心使用值对象或端口和适配器架构。但如果你正在处理一个高度复杂的领域,我会考虑这一点。


设计
我们使用 Java 记录record,因为它们提供不变性并根据值自动生成equals和hashCode验证。此外,在构建它们时编写验证非常简单。

那么,将值对象存储在数据库中时,它们存在什么问题?

  • 它们本质上是包装值的对象,这意味着它们在关系数据库中的存储不是原子的,并且违反了第一范式 (1NF)。
  • 我们还必须区分只包含一个值的简单值对象和更复杂的值对象,例如具有Location多个值的对象。
根据复杂性,存储和转换它们的方式会有所不同。

我们将通过一个简单的示例,使用一个Order包含Item值对象集合和一个Payment值对象的实体。
本文重点介绍带有 Hibernate 的 Spring Data JPA,我们将使用 PostgreSQL 作为数据库。

对于简单的值对象,只需用 注释类型@Embeddable并用@Embedded 注释字段。

@Embeddable
public record Payment(BigDecimal value) {
}

@Embeddable
public record OrderItemName(String value) {

    public OrderItemName {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Name cannot be null or empty");
        }
    }

}

@Embeddable
@AttributeOverrides({
        @AttributeOverride(name =
"name.value", column = @Column(name = "name")),
        @AttributeOverride(name =
"amount.value", column = @Column(name = "amount"))
})
public record OrderItem(UUID id, @Embedded OrderItemName name, @Embedded OrderItemPaymentAmount amount) {
}

@Entity
@Table(name =
"orders")
@AllArgsConstructor
@NoArgsConstructor
@Accessors(fluent = true)
@AttributeOverrides({
        @AttributeOverride(name =
"payment.value", column = @Column(name = "payment"))
})
public class Order {

    @Id
    private UUID id;
    @Embedded
    private Payment payment;
    @ElementCollection
    @CollectionTable(name =
"order_items", joinColumns = @JoinColumn(name = "order_id"))
    private List<OrderItem> orderItems;

}

对于更复杂的值对象,我们需要在每个字段上添加 @AttributeOverrides 和 @Embedded。

由于我们有一个项目列表,我们还在实体上使用了 @ElementCollection,因为它映射到了一个新表。
我们本可以对 OrderItem 使用 @OneToMany,但我们设计的 OrderItem 并不是一个独立的实体,因为它与父订单紧密相连--没有订单,OrderItem 就不存在。这就是我们选择 @ElementCollection 的原因。

从性能的角度来看,我们需要权衡利弊。 让 OrderItem 成为自己的实体会给您带来更大的灵活性,但会导致更复杂的连接,并可能降低性能。

@ElementCollection 可能会为小型集合提供更好的性能,但对于大型集合,由于优化机会较少,运行查询可能会更慢。 不过,您可以使用 Flyway 手动创建索引,这样在这两种情况下都能获得更大的灵活性。

注意

  • 重复使用具有不同列名的值对象: 如果您想用不同的列名存储同一个值对象,那么在实体中嵌入 @Embeddable 时使用 @AttributeOverride 来更改列名。
  • 避免嵌入式对象的复杂层次结构: 一般来说,避免使用 @Embeddable 类的复杂层次结构是个好主意。 但如果需要使用它们,可以考虑使用 @MappedSuperclass。

总结:
有了这种方法,我们就可以根据战术性的 DDD 方法,以简洁的方式在数据库中存储值对象。 虽然我仍然更倾向于采用一种更解耦(decoupled)的方法,在这种方法中,值对象和其他领域对象的创建与框架无关,但在我看来,这也是一种很好的折衷方法。

对于 MongoDB 来说,使用 Spring Data 实现一个监听器是非常简单的,它可以在存储前进行拦截,并按照我们的要求转换对象。

虽然 Hibernate 提供了属性转换器(AttributeConverter),但我还没有找到一种通用方法来根据已实现的标记接口等应用该转换器。 我们需要为每种类型生成一个 AttributeConverter,在我看来这有点繁琐。 因此,我赞成这种方法。