在上一部分,介绍了设计域模型的基本设计原则和模式。 现在,是时候探索使用 .NET(普通 C# 代码)和 EF Core 实现域模型的可能方法了。 你的域模型将完全由代码组成。 它只具有 EF Core 模型要求,但不具有 EF 上的实际依赖项。 您不应在域模型中对 EF Core 或任何其他 ORM 产生硬依赖或引用。
自定义 .NET 标准库中的域模型结构
用于 eShopOnContainers 参考应用程序的文件夹组织演示应用程序的 DDD 模型。 你可能会发现不同的文件夹组织更清楚地传达为应用程序所做的设计选择。 如图 7-10 所示,在排序域模型中有两个聚合,即订单聚合和买家聚合。 每个聚合都是一组域实体和值对象,尽管也可以具有由单个域实体(聚合根实体或根实体)组成的聚合。
               
              
            
Ordering.Domain 项目的解决方案资源管理器视图,显示包含 BuyerAggregate 和 OrderAggregate 文件夹的 AggregatesModel 文件夹,每个文件夹都包含其实体类、值对象文件等。
图 7-10. eShopOnContainers 中订购微服务的域模型结构
此外,域模型层还包括存储库协定(接口),这些协定是域模型的基础结构要求。 换句话说,这些接口表示基础结构层必须实现哪些存储库和方法。 至关重要的是,存储库的实现放置在基础结构层库中的域模型层外部,因此域模型层不会受到 API 或基础结构技术(如 Entity Framework)的类的“污染”。
你还可以看到一个 SeedWork 文件夹,其中包含自定义基类,这些基类可用于域实体和值对象的基类,因此在每个域的对象类中没有冗余代码。
在自定义 .NET Standard 库中构造聚合
聚合是指分组在一起的域对象的群集,以匹配事务一致性。 这些对象可以是实体的实例(其中一个是聚合根实体或根实体),以及任何其他值对象。
事务一致性意味着保证聚合体在业务操作结束时保持一致性和是最新的。 例如,图 7-11 显示了 eShopOnContainers 订购微服务域模型中的订单聚合体的组成。
               
              
            
OrderAggregate 文件夹的详细视图:Address.cs是值对象,IOrderRepository 是存储库接口,Order.cs是聚合根,OrderItem.cs是子实体,OrderStatus.cs是枚举类。
图 7-11. Visual Studio 解决方案中的订单汇总
如果打开聚合文件夹中的任何文件,可以看到它被标记为自定义基类或接口,比如实体或值对象,这些都是在SeedWork 文件夹中实现的。
将域实体实现为 POCO 类
通过创建实现域实体的 POCO 类,在 .NET 中实现域模型。 在以下示例中,Order 类定义为实体,也定义为聚合根。 由于 Order 类派生自 Entity 基类,因此可以重复使用与实体相关的常见代码。 请记住,这些基类和接口由你在域模型项目中定义,因此它是你的代码,而不是来自 ORM(如 EF)的基础结构代码。
// COMPATIBLE WITH ENTITY FRAMEWORK CORE 5.0
// Entity is a custom base class with the ID
public class Order : Entity, IAggregateRoot
{
    private DateTime _orderDate;
    public Address Address { get; private set; }
    private int? _buyerId;
    public OrderStatus OrderStatus { get; private set; }
    private int _orderStatusId;
    private string _description;
    private int? _paymentMethodId;
    private readonly List<OrderItem> _orderItems;
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems;
    public Order(string userId, Address address, int cardTypeId, string cardNumber, string cardSecurityNumber,
            string cardHolderName, DateTime cardExpiration, int? buyerId = null, int? paymentMethodId = null)
    {
        _orderItems = new List<OrderItem>();
        _buyerId = buyerId;
        _paymentMethodId = paymentMethodId;
        _orderStatusId = OrderStatus.Submitted.Id;
        _orderDate = DateTime.UtcNow;
        Address = address;
        // ...Additional code ...
    }
    public void AddOrderItem(int productId, string productName,
                            decimal unitPrice, decimal discount,
                            string pictureUrl, int units = 1)
    {
        //...
        // Domain rules/logic for adding the OrderItem to the order
        // ...
        var orderItem = new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units);
        _orderItems.Add(orderItem);
    }
    // ...
    // Additional methods with domain rules/logic related to the Order aggregate
    // ...
}
请务必注意,这是作为 POCO 类实现的域实体。 它不直接依赖于 Entity Framework Core 或任何其他基础结构框架。 此实现符合 DDD 原则,只是通过 C# 代码实现一个域模型。
此外,该类用名为 IAggregateRoot 的接口修饰。 该接口是一个空接口,有时称为 标记接口,仅用于指示此实体类也是聚合根。
标记接口有时被视为一种反模式,但它也是标记一个类的简洁方法,特别是在该接口可能会不断演变的情况下。 属性可以作为标记的另一种选择,但将基类(Entity)放在 IAggregate 接口旁边会更容易查看,而不是在类上方添加一个属性标记。 无论如何,这是一个偏好问题。
具有聚合根意味着,与聚合实体的一致性和业务规则相关的大部分代码都应作为 Order 聚合根类中的方法实现(例如,在向聚合中添加 OrderItem 对象时添加 AddOrderItem)。 不应单独或直接创建或更新 OrderItems 对象;AggregateRoot 类必须对其子实体保持任何更新作的控制和一致性。
在域实体中封装数据
实体模型中的一个常见问题是,它们将集合导航属性公开为可公开访问的列表类型。 这样,任何开发者都可以操作这些集合类型的内容,这可能会绕过与集合相关的重要业务规则,可能导致对象处于无效状态。 解决方法是开放对相关集合的只读访问,并显式提供方法来定义客户端如何操作它们。
在前面的代码中,请注意,许多属性都是只读的或私有的,并且只能通过类方法进行更新,因此任何更新都考虑业务域固定和类方法中指定的逻辑。
例如,遵循 DDD 模式,不应从任何命令处理程序方法或应用程序层类执行以下命令(实际上,你不可能这样做):
// WRONG ACCORDING TO DDD PATTERNS – CODE AT THE APPLICATION LAYER OR
// COMMAND HANDLERS
// Code in command handler methods or Web API controllers
//... (WRONG) Some code with business logic out of the domain classes ...
OrderItem myNewOrderItem = new OrderItem(orderId, productId, productName,
    pictureUrl, unitPrice, discount, units);
//... (WRONG) Accessing the OrderItems collection directly from the application layer // or command handlers
myOrder.OrderItems.Add(myNewOrderItem);
//...
在这种情况下,Add 方法纯粹是一个添加数据的操作,可以直接访问 OrderItems 集合。 因此,与子实体相关的大多数域逻辑、规则或验证将分布在应用程序层(命令处理程序和 Web API 控制器)中。
如果绕过聚合根,聚合根无法保证其固定性、有效性或其一致性。 最终将产生面条式代码或事务脚本代码。
若要遵循 DDD 模式,实体不能在任何实体属性中拥有公共 setter。 实体中的更改应由显式方法驱动,这些方法具有有关它们在实体中执行的更改的显式无处不在语言。
此外,实体中的集合(如订单项)应该是只读属性(稍后介绍的 AsReadOnly 方法)。 应该只能从聚合根类方法或子实体方法内部更新它。
从 Order 聚合根的代码中可以看到,所有 setter 都应该是私有的,或者至少是从外部只读的,因此针对实体数据或其子实体的任何操作都必须通过实体类中的方法来执行。 这以受控且面向对象的方式保持一致性,而不是实现事务脚本代码。
以下代码片段显示了将 OrderItem 对象添加到 Order 聚合的任务的正确编码方法。
// RIGHT ACCORDING TO DDD--CODE AT THE APPLICATION LAYER OR COMMAND HANDLERS
// The code in command handlers or WebAPI controllers, related only to application stuff
// There is NO code here related to OrderItem object's business logic
myOrder.AddOrderItem(productId, productName, pictureUrl, unitPrice, discount, units);
// The code related to OrderItem params validations or domain rules should
// be WITHIN the AddOrderItem method.
//...
在此代码片段中,与创建 OrderItem 对象相关的大多数验证或逻辑都将在 AddOrderItem 方法中的 Order 聚合根(尤其是与聚合中的其他元素相关的验证和逻辑)的控制下。 例如,经过多次调用 AddOrderItem,你可能会获得相同的产品。 在该方法中,可以检查产品项,并将相同的产品项合并到具有多个单元的单个 OrderItem 对象中。 此外,如果存在不同的折扣金额,但产品 ID 相同,则可能应用更高的折扣。 此原则适用于 OrderItem 对象的任何其他域逻辑。
此外,新的 OrderItem(params)操作也将由 Order 聚合根中的 AddOrderItem 方法进行控制和执行。 因此,与该作相关的大多数逻辑或验证(尤其是影响其他子实体之间的一致性的任何内容)都将位于聚合根内的单个位置。 这是聚合根模式的最终目的。
使用 Entity Framework Core 1.1 或更高版本时,DDD 实体可以更好地表示,因为它允许除属性外 映射到字段 。 这在保护子实体或值对象的集合时非常有用。 通过此增强功能,可以使用简单的专用字段而不是属性,并且可以在公共方法中实现对字段集合的任何更新,并通过 AsReadOnly 方法提供只读访问权限。
在 DDD 中,你希望仅通过实体(或构造函数)中的方法更新实体,以便控制数据的任何固定性和一致性,因此仅使用 get 访问器定义属性。 这些属性由专用字段提供支持。 只能从类中访问私有成员。 但是,有一个例外:EF Core 也需要设置这些字段(因此它可以返回具有正确值的对象)。
将仅具有 get 取值函数的属性映射到数据库表中的字段
将属性映射到数据库表列不是域责任,而是基础结构和持久性层的一部分。 我们在这里提到这一点,以便你了解 EF Core 1.1 或更高版本中与如何对实体建模相关的新功能。 有关本主题的更多详细信息,请参阅基础结构和持久性部分。
使用 EF Core 1.0 或更高版本时,需要在 DbContext 中将仅使用 getter 定义的属性映射到数据库表中的实际字段。 这是使用 PropertyBuilder 类的 HasField 方法完成的。
映射不含属性的字段
通过借助 EF Core 1.1 或更高版本中的功能将列映射到字段,也可以不使用属性。 相反,只需将表中的列映射到字段。 一个常见用例是内部状态的专用字段,不需要从实体外部访问。
例如,在前面的 OrderAggregate 代码示例中,有几个私有字段(如 _paymentMethodId 字段)没有 setter 或 getter 的相关属性。 该字段还可以在订单的业务逻辑中计算,并从订单的方法中使用,但还需要保存在数据库中。 因此,在 EF Core(自 v1.1 起)中,有一种方法将没有相关属性的字段映射到数据库中的列。 本指南的 “基础结构层” 部分也对此进行了介绍。
其他资源
- Vaughn Vernon。 使用 DDD 和 Entity Framework 对聚合进行建模。 请注意,这不是Entity Framework Core。 
 https://kalele.io/blog-posts/modeling-aggregates-with-ddd-and-entity-framework/
- 朱莉·勒曼 数据点 - 域驱动设计的编码:数据聚焦型开发的技巧 
 https://free.blessedness.top/archive/msdn-magazine/2013/august/data-points-coding-for-domain-driven-design-tips-for-data-focused-devs
- 乌迪·达汉 如何创建完全封装的域模型 
 https://udidahan.com/2008/02/29/how-to-create-fully-encapsulated-domain-models/
- 史蒂夫·史密斯 DTO 和 POCO 有何区别? \ https://ardalis.com/dto-or-poco/ 
