使用 .NET 实现微服务域模型

In the previous section, the fundamental design principles and patterns for designing a domain model were explained. Now it's time to explore possible ways to implement the domain model by using .NET (plain C# code) and EF Core. Your domain model will be composed simply of your code. It will have just the EF Core model requirements, but not real dependencies on EF. You shouldn't have hard dependencies or references to EF Core or any other ORM in your domain model.

在上一节中,我们解释了设计领域模型的基本设计原则和模式。现在是时候探索如何使用 .NET(纯 C# 代码)和 EF Core 来实现领域模型了。你的领域模型将完全由你自己的代码组成。它只会满足 EF Core 模型的要求,但不会对 EF 产生真正的依赖。你不应该在领域模型中对 EF Core 或任何其他 ORM 存在硬依赖或引用。

Domain model structure in a custom .NET Standard Library    自定义 .NET Standard 类库中的领域模型结构

The folder organization used for the eShopOnContainers reference application demonstrates the DDD model for the application. You might find that a different folder organization more clearly communicates the design choices made for your application. As you can see in Figure 7-10, in the ordering domain model there are two aggregates, the order aggregate and the buyer aggregate. Each aggregate is a group of domain entities and value objects, although you could have an aggregate composed of a single domain entity (the aggregate root or root entity) as well.

eShopOnContainers 参考应用所使用的文件夹组织结构,展示了该应用的 DDD 模型。你可能会发现,不同的文件夹组织结构能更清晰地传达你为应用所做的设计选择。正如你在图 7-10 中所看到的,在订购领域模型中有两个聚合:订单(order)聚合和买家(buyer)聚合。每个聚合都是一组领域实体和值对象的集合,不过,你也完全可以拥有一个仅由单个领域实体(即聚合根或根实体)组成的聚合。

Screenshot of the Ordering.Domain project in Solution Explorer.

The Solution Explorer view for the Ordering.Domain project, showing the AggregatesModel folder containing the BuyerAggregate and OrderAggregate folders, each one containing its entity classes, value object files and so on.

Ordering.Domain 项目的“解决方案资源管理器”视图,展示了包含 BuyerAggregate(买家聚合)和 OrderAggregate(订单聚合)文件夹的 AggregatesModel 文件夹,其中每个聚合文件夹内又分别包含了各自的实体类、值对象文件等。

Figure 7-10. Domain model structure for the ordering microservice in eShopOnContainers

图 7-10. eShopOnContainers 中订购微服务的领域模型结构

Additionally, the domain model layer includes the repository contracts (interfaces) that are the infrastructure requirements of your domain model. In other words, these interfaces express what repositories and the methods the infrastructure layer must implement. It's critical that the implementation of the repositories be placed outside of the domain model layer, in the infrastructure layer library, so the domain model layer isn't "contaminated" by API or classes from infrastructure technologies, like Entity Framework.

此外,领域模型层还包括仓储契约(即接口),这些契约代表了领域模型对基础设施的需求。换句话说,这些接口明确了基础设施层必须实现的仓储及其方法。非常关键的一点是,仓储的具体实现必须放在领域模型层之外(即基础设施层的类库中),这样领域模型层就不会被来自基础设施技术(如 Entity Framework)的 API 或类所“污染”。

You can also see a SeedWork folder that contains custom base classes that you can use as a base for your domain entities and value objects, so you don't have redundant code in each domain's object class.

你还可以看到一个名为 SeedWork 的文件夹,其中包含了自定义的基类。你可以将这些基类作为领域实体和值对象的基类,这样就不必在每个领域对象类中编写重复的代码了。

Structure aggregates in a custom .NET Standard library    在自定义 .NET Standard 类库中构建聚合

An aggregate refers to a cluster of domain objects grouped together to match transactional consistency. Those objects could be instances of entities (one of which is the aggregate root or root entity) plus any additional value objects.

聚合是指为了匹配事务一致性而被组合在一起的一组领域对象集群。这些对象可以是实体的实例(其中一个是聚合根或根实体),以及任何附加的值对象。

Transactional consistency means that an aggregate is guaranteed to be consistent and up to date at the end of a business action. For example, the order aggregate from the eShopOnContainers ordering microservice domain model is composed as shown in Figure 7-11.

事务一致性意味着在业务操作结束时,聚合能够被保证处于一致且最新的状态。例如,来自 eShopOnContainers 订购微服务领域模型的订单(order)聚合,其组成结构如图 7-11 所示。

Screenshot of the OrderAggregate folder and its classes.

A detailed view of the OrderAggregate folder: Address.cs is a value object, IOrderRepository is a repo interface, Order.cs is an aggregate root, OrderItem.cs is a child entity, and OrderStatus.cs is an enumeration class.

OrderAggregate 文件夹的详细内容如下:

  • Address.cs:值对象(Value Object)
  • IOrderRepository.cs:仓储接口(Repository Interface)
  • Order.cs:聚合根(Aggregate Root)
  • OrderItem.cs:子实体(Child Entity)
  • OrderStatus.cs:枚举类(Enumeration Class)

Figure 7-11. The order aggregate in Visual Studio solution

图 7-11. Visual Studio 解决方案中的订单(order)聚合

If you open any of the files in an aggregate folder, you can see how it's marked as either a custom base class or interface, like entity or value object, as implemented in the SeedWork folder.

如果你打开聚合文件夹中的任意一个文件,可以看到它是如何被标记为自定义基类或接口的(比如实体或值对象),这些基类或接口正是在 SeedWork 文件夹中定义的。

Implement domain entities as POCO classes    将领域实体实现为 POCO 类

You implement a domain model in .NET by creating POCO classes that implement your domain entities. In the following example, the Order class is defined as an entity and also as an aggregate root. Because the Order class derives from the Entity base class, it can reuse common code related to entities. Bear in mind that these base classes and interfaces are defined by you in the domain model project, so it is your code, not infrastructure code from an ORM like EF.

在 .NET 中,你可以通过创建 POCO(简单传统 CLR 对象)类来实现你的领域实体,从而构建出领域模型。在下面的示例中,Order 类被定义为一个实体,同时也是一个聚合根。由于 Order 类派生自 Entity 基类,因此它可以复用与实体相关的通用代码。请记住,这些基类和接口是由你在领域模型项目中自行定义的,所以它们完全是你自己的代码,而不是像 EF 这样的 ORM 框架提供的基础设施代码。

// 兼容 Entity Framework Core 5.0 版本
// Entity 是一个自定义的基类,封装了实体的唯一标识符 (ID)
/// <summary>
/// 订单聚合根(Aggregate Root),作为订单限界上下文的核心实体,负责维护订单状态及订单项的一致性。
/// </summary>
public class Order : Entity, IAggregateRoot
{
    /// <summary>
    /// 订单创建日期私有字段,通过 EF Core 进行映射。
    /// </summary>
    private DateTime _orderDate;

    /// <summary>
    /// 获取订单的收货地址(值对象)。
    /// </summary>
    public Address Address { get; private set; }

    /// <summary>
    /// 买家 ID 私有字段,用于关联买家信息。
    /// </summary>
    private int? _buyerId;

    /// <summary>
    /// 获取当前订单的状态枚举。
    /// </summary>
    public OrderStatus OrderStatus { get; private set; }

    /// <summary>
    /// 订单状态的内部 ID,用于数据库持久化。
    /// </summary>
    private int _orderStatusId;

    /// <summary>
    /// 订单描述信息的私有字段。
    /// </summary>
    private string _description;

    /// <summary>
    /// 支付方式 ID 私有字段,用于关联支付信息。
    /// </summary>
    private int? _paymentMethodId;

    /// <summary>
    /// 订单明细项的内部集合,防止外部直接修改以维护聚合根的封装性。
    /// </summary>
    private readonly List<OrderItem> _orderItems;

    /// <summary>
    /// 获取只读的订单明细项集合,供外部查询使用。
    /// </summary>
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems;

    /// <summary>
    /// 初始化一个新的订单实例。
    /// </summary>
    /// <param name="userId">下单用户的唯一标识。</param>
    /// <param name="address">订单的收货地址。</param>
    /// <param name="cardTypeId">使用的卡片类型 ID。</param>
    /// <param name="cardNumber">卡号。</param>
    /// <param name="cardSecurityNumber">卡片安全码。</param>
    /// <param name="cardHolderName">持卡人姓名。</param>
    /// <param name="cardExpiration">卡片过期时间。</param>
    /// <param name="buyerId">可选参数,买家的 ID。</param>
    /// <param name="paymentMethodId">可选参数,默认支付方式的 ID。</param>
    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;                  // 设置买家 ID
        _paymentMethodId = paymentMethodId;  // 设置支付方式 ID
        _orderStatusId = OrderStatus.Submitted.Id; // 新订单默认状态为“已提交”
        _orderDate = DateTime.UtcNow;        // 记录订单创建的 UTC 时间
        Address = address;                   // 赋值收货地址

        // ...其他业务初始化代码...
    }

    /// <summary>
    /// 向当前订单中添加一个订单项。包含添加商品时的核心领域规则与逻辑校验。
    /// </summary>
    /// <param name="productId">商品的唯一 ID。</param>
    /// <param name="productName">商品名称。</param>
    /// <param name="unitPrice">商品单价。</param>
    /// <param name="discount">折扣金额或比例。</param>
    /// <param name="pictureUrl">商品图片链接。</param>
    /// <param name="units">购买数量,默认为 1。</param>
    public void AddOrderItem(int productId, string productName,
                            decimal unitPrice, decimal discount,
                            string pictureUrl, int units = 1)
    {
        // ...
        // 此处可添加针对添加订单项的领域规则/逻辑(例如:检查商品是否已在订单中、库存校验等)
        // ...

        // 创建新的订单项(值对象/实体)
        var orderItem = new OrderItem(productId, productName, unitPrice, discount, pictureUrl, units);

        // 将新项加入内部集合
        _orderItems.Add(orderItem);
    }

    // ...
    // 其他与订单聚合相关的领域规则和逻辑方法(如:更新订单状态、取消订单等)
    // ...
}

It's important to note that this is a domain entity implemented as a POCO class. It doesn't have any direct dependency on Entity Framework Core or any other infrastructure framework. This implementation is as it should be in DDD, just C# code implementing a domain model.

值得注意的是,这是一个作为 POCO 类实现的领域实体。它不直接依赖 Entity Framework Core 或任何其他基础设施框架。这种实现方式正是 DDD 所提倡的,即仅仅使用 C# 代码来实现领域模型。

In addition, the class is decorated with an interface named IAggregateRoot. That interface is an empty interface, sometimes called a marker interface, that's used just to indicate that this entity class is also an aggregate root.

此外,该类还装饰了一个名为 IAggregateRoot 的接口。这个接口是一个空接口,有时也被称为标记接口(marker interface),它的作用仅仅是表明这个实体类同时也是一个聚合根。

A marker interface is sometimes considered as an anti-pattern; however, it's also a clean way to mark a class, especially when that interface might be evolving. An attribute could be the other choice for the marker, but it's quicker to see the base class (Entity) next to the IAggregate interface instead of putting an Aggregate attribute marker above the class. It's a matter of preferences, in any case.

标记接口有时被认为是一种反模式;不过,它也是一种标记类的干净方式,尤其是当该接口未来可能会演进时。使用特性(Attribute)是另一种选择,但直接在类名旁边看到基类(Entity)和 IAggregate 接口,会比在类上方放置一个 Aggregate 特性标记看起来更直观。当然,这纯粹是个人偏好的问题。

Having an aggregate root means that most of the code related to consistency and business rules of the aggregate's entities should be implemented as methods in the Order aggregate root class (for example, AddOrderItem when adding an OrderItem object to the aggregate). You should not create or update OrderItems objects independently or directly; the AggregateRoot class must keep control and consistency of any update operation against its child entities.

拥有聚合根意味着,与聚合内实体的一致性和业务规则相关的大部分代码,都应该作为方法实现在聚合根类中(例如,当向聚合中添加 OrderItem 对象时,使用 AddOrderItem 方法)。你不应该独立地或直接地创建或更新 OrderItem 对象;AggregateRoot 类必须对其子实体的任何更新操作保持控制和一致性。

Encapsulate data in the Domain Entities    在领域实体中封装数据

A common problem in entity models is that they expose collection navigation properties as publicly accessible list types. This allows any collaborator developer to manipulate the contents of these collection types, which may bypass important business rules related to the collection, possibly leaving the object in an invalid state. The solution to this is to expose read-only access to related collections and explicitly provide methods that define ways in which clients can manipulate them.

实体模型中一个常见的问题是,它们将集合导航属性暴露为公开可访问的列表类型(比如 public List<T>)。这使得任何协作开发的程序员都能随意操纵这些集合类型的内容,这可能会绕过与集合相关的重要业务规则,从而导致对象处于无效状态。解决这个问题的办法是:对外只暴露对关联集合的只读访问权限,并显式地提供定义客户端如何操作这些集合的方法。

In the previous code, note that many attributes are read-only or private and are only updatable by the class methods, so any update considers business domain invariants and logic specified within the class methods.

在上面的代码中,请注意许多属性都是只读的或私有的,并且只能通过类的方法来更新,因此任何更新操作都会考虑到业务领域的不变量(invariants)以及类方法中指定的逻辑。

For example, following DDD patterns, you should not do the following from any command handler method or application layer class (actually, it should be impossible for you to do so):

例如,遵循 DDD 模式,你不应该在任何命令处理程序(command handler)方法或应用层类中执行以下操作(实际上,你根本就不应该能这样做):

// 这种做法违反了 DDD(领域驱动设计)模式——不应将业务逻辑写在应用层或命令处理程序中
// 以下代码位于命令处理方法(Command Handlers)或 Web API 控制器中
//... (错误做法)在领域类之外编写了包含核心业务逻辑的代码 ...
OrderItem myNewOrderItem = new OrderItem(orderId, productId, productName,
    pictureUrl, unitPrice, discount, units);

//... (错误做法)从应用层或命令处理程序直接访问并修改了订单明细集合(破坏了聚合根的封装性)
myOrder.OrderItems.Add(myNewOrderItem);
//...

In this case, the Add method is purely an operation to add data, with direct access to the OrderItems collection. Therefore, most of the domain logic, rules, or validations related to that operation with the child entities will be spread across the application layer (command handlers and Web API controllers).

在这种情况下,Add 方法纯粹只是一个用来添加数据的操作,它直接访问了 OrderItems 集合。因此,大部分与该子实体操作相关的领域逻辑、规则或验证,都会分散到应用层(命令处理程序和 Web API 控制器)中去。

If you go around the aggregate root, the aggregate root cannot guarantee its invariants, its validity, or its consistency. Eventually you'll have spaghetti code or transactional script code.

如果你绕过聚合根去操作,聚合根就无法保证其不变量(invariants)、有效性或一致性。最终,你的代码会变成一团乱麻,或者沦为事务性脚本代码。

To follow DDD patterns, entities must not have public setters in any entity property. Changes in an entity should be driven by explicit methods with explicit ubiquitous language about the change they're performing in the entity.

为了遵循 DDD 模式,实体的任何属性都不应该有公共的 setter。对实体的更改应该由显式的方法来驱动,并且这些方法应该使用显式的通用语言(ubiquitous language)来描述它们正在对实体执行的操作。

Furthermore, collections within the entity (like the order items) should be read-only properties (the AsReadOnly method explained later). You should be able to update it only from within the aggregate root class methods or the child entity methods.

此外,实体内部的集合(比如订单项)应该是只读属性(稍后会讲解 AsReadOnly 方法)。你应该只能通过聚合根类的方法或子实体内部的方法来更新它。

As you can see in the code for the Order aggregate root, all setters should be private or at least read-only externally, so that any operation against the entity's data or its child entities has to be performed through methods in the entity class. This maintains consistency in a controlled and object-oriented way instead of implementing transactional script code.

正如你在订单(Order)聚合根的代码中看到的,所有的 setter 都应该是私有的,或者至少对外部是只读的,这样对实体数据或其子实体的任何操作都必须通过实体类中的方法来执行。这种方式以受控且面向对象的方式维护了一致性,而不是去实现事务性脚本代码。

The following code snippet shows the proper way to code the task of adding an OrderItem object to the Order aggregate.

下面的代码片段展示了将 OrderItem 对象添加到 Order 聚合中的正确编码方式。

// 根据 DDD 规范,以下代码位于应用层或命令处理器(Command Handlers)中。
// 命令处理器或 WebAPI 控制器中的代码仅负责处理应用程序级别的事务(如参数传递、流程编排)。
// 此处绝对不包含任何与 OrderItem 对象相关的业务逻辑。
myOrder.AddOrderItem(productId, productName, pictureUrl, unitPrice, discount, units);

// 与 OrderItem 参数校验或核心领域规则相关的代码,
// 必须封装在聚合根内部的 AddOrderItem 方法中(即属于领域层的职责)。

//...

In this snippet, most of the validations or logic related to the creation of an OrderItem object will be under the control of the Order aggregate root—in the AddOrderItem method—especially validations and logic related to other elements in the aggregate. For instance, you might get the same product item as the result of multiple calls to AddOrderItem. In that method, you could examine the product items and consolidate the same product items into a single OrderItem object with several units. Additionally, if there are different discount amounts but the product ID is the same, you would likely apply the higher discount. This principle applies to any other domain logic for the OrderItem object.

在这段代码片段中,大部分与创建 OrderItem 对象相关的验证或逻辑,都将由订单(Order)聚合根来控制——具体来说就是在 AddOrderItem 方法中——尤其是那些与聚合内其他元素相关的验证和逻辑。例如,多次调用 AddOrderItem 可能会得到相同的商品项。在这个方法中,你可以检查这些商品项,并将相同的商品项合并为一个包含多个单位的 OrderItem 对象。此外,如果商品 ID 相同但折扣金额不同,你通常会应用更高的那个折扣。这一原则适用于 OrderItem 对象的任何其他领域逻辑。

In addition, the new OrderItem(params) operation will also be controlled and performed by the AddOrderItem method from the Order aggregate root. Therefore, most of the logic or validations related to that operation (especially anything that impacts the consistency between other child entities) will be in a single place within the aggregate root. That is the ultimate purpose of the aggregate root pattern.

此外,新的 OrderItem(params) 操作也将由订单聚合根的 AddOrderItem 方法来控制和执行。因此,大部分与该操作相关的逻辑或验证(尤其是任何影响其他子实体之间一致性的逻辑),都将集中在聚合根内的一个地方。这正是聚合根模式的最终目的。

When you use Entity Framework Core 1.1 or later, a DDD entity can be better expressed because it allows mapping to fields in addition to properties. This is useful when protecting collections of child entities or value objects. With this enhancement, you can use simple private fields instead of properties and you can implement any update to the field collection in public methods and provide read-only access through the AsReadOnly method.

当你使用 Entity Framework Core 1.1 或更高版本时,DDD 实体可以得到更好的表达,因为它允许将映射直接指向字段(fields)而不仅仅是属性(properties)。这在保护子实体或值对象的集合时非常有用。有了这个增强功能,你可以使用简单的私有字段来代替属性,并且可以在公共方法中实现对字段集合的任何更新,同时通过 AsReadOnly 方法提供只读访问权限。

In DDD, you want to update the entity only through methods in the entity (or the constructor) in order to control any invariant and the consistency of the data, so properties are defined only with a get accessor. The properties are backed by private fields. Private members can only be accessed from within the class. However, there is one exception: EF Core needs to set these fields as well (so it can return the object with the proper values).

在 DDD 中,你希望仅通过实体中的方法(或构造函数)来更新实体,以便控制任何不变量(invariants)和数据的一致性,因此属性通常只定义 get 访问器。这些属性由私有字段作为后端支撑。私有成员通常只能从类内部访问。不过,有一个例外:EF Core 也需要能够设置这些字段(以便它能返回带有正确数值的对象)。

Map properties with only get accessors to the fields in the database table    将只有 get 访问器的属性映射到数据库表字段

Mapping properties to database table columns is not a domain responsibility but part of the infrastructure and persistence layer. We mention this here just so you're aware of the new capabilities in EF Core 1.1 or later related to how you can model entities. Additional details on this topic are explained in the infrastructure and persistence section.

将属性映射到数据库表列并不是领域的职责,而是基础设施和持久化层的一部分。我们在这里提到这一点,只是为了让你了解 EF Core 1.1 或更高版本中,关于如何对实体进行建模的新能力。关于此主题的更多细节将在基础设施和持久化层部分进行解释。

When you use EF Core 1.0 or later, within the DbContext you need to map the properties that are defined only with getters to the actual fields in the database table. This is done with the HasField method of the PropertyBuilder class.

当你使用 EF Core 1.0 或更高版本时,在 DbContext 中,你需要将那些只定义了 getter 的属性映射到数据库表中的实际字段。这是通过 PropertyBuilder 类的 HasField 方法来完成的。

Map fields without properties    映射没有属性的字段

With the feature in EF Core 1.1 or later to map columns to fields, it's also possible to not use properties. Instead, you can just map columns from a table to fields. A common use case for this is private fields for an internal state that doesn't need to be accessed from outside the entity.

借助 EF Core 1.1 或更高版本中将列映射到字段的功能,甚至可以完全不使用属性。相反,你可以直接将表中的列映射到字段。一个常见的用例是用于内部状态的私有字段,这些状态不需要从实体外部访问。

For example, in the preceding OrderAggregate code example, there are several private fields, like the _paymentMethodId field, that have no related property for either a setter or getter. That field could also be calculated within the order's business logic and used from the order's methods, but it needs to be persisted in the database as well. So in EF Core (since v1.1), there's a way to map a field without a related property to a column in the database. This is also explained in the Infrastructure layer section of this guide.

例如,在前面的 OrderAggregate 代码示例中,有几个私有字段,比如 _paymentMethodId 字段,它没有相关的 setter 或 getter 属性。该字段也可以在订单的业务逻辑中进行计算,并从订单的方法中使用,但它同样需要被持久化到数据库中。因此,在 EF Core(从 v1.1 开始)中,有一种方法可以将一个没有相关属性的字段映射到数据库中的列。这也会在本指南的基础设施层部分进行解释。

posted @ 2026-05-17 18:23  菜鸟吊思  阅读(13)  评论(0)    收藏  举报