领域驱动设计:我的 5 大最佳实践





5.00/5 (8投票s)
领域驱动设计 - 我的 5 大最佳实践
引言
大约 3 年前,我写了 《应用领域驱动设计系列》,并创建了一个 “领域驱动设计示例” Github 仓库。
我注意到,我们在学习 DDD 的过程中都会犯同样的错误。在这篇文章中,我将尝试解决一些我见过或犯过的最常见的错误。这个最佳实践列表是有限的,请同时查看 Daniel Whittaker 的 "10 个领域驱动设计错误"。
1. 始终从需求与建模开始
我记得第一次发现 Eric Evans 的那本蓝皮书时。我读了它,非常喜欢。然而,我最兴奋的是抛弃贫血模型,引入聚合根、领域服务等。我错过了领域驱动设计的要点。关键在于你应该沉浸在业务需求中,多问业务问题,并与业务分析师一起在白板上建模。相反,我最终建模了我自己的、非业务导向的技术现实。这个模型无法长期维护,不得不重写。始终从需求和建模开始来避免这种情况。
2. 尽可能保持聚合根扁平化
当我刚开始使用 DDD 时,我对深度对象图嵌套的想法非常兴奋。想象一下,你有一个对象 Customer
,customer
对象可以访问 Cart
、Purchases
、Credit Cards
等。Purchases
可以访问购买的 items
,依此类推。这很令人兴奋,因为我可以使用 Specification pattern 来遍历我的模型。这很令人兴奋,因为它使我能够编写复杂的领域查询,并将查询输出投影到非常复杂的 UI 上。不幸的是,很快,这使得单元测试非常困难,也使得应用程序难以维护。
以下是一个深度对象图的例子
public class Customer : IAggregateRoot
{
private List<Purchase> purchases = new List<Purchase>();
private List<CreditCard> creditCards = new List<CreditCard>();
public virtual Guid Id { get; protected set; }
public virtual string FirstName { get; protected set; }
public virtual string LastName { get; protected set; }
public virtual string Email { get; protected set; }
public virtual string Password { get; protected set; }
public virtual DateTime Created { get; protected set; }
public virtual bool Active { get; protected set; }
public virtual decimal Balance { get; protected set; }
public virtual Country Country { get; protected set; }
public virtual ReadOnlyCollection<Purchase> Purchases
{ get { return this.purchases.AsReadOnly(); } }
public virtual ReadOnlyCollection<CreditCard> CreditCards
{ get { return this.creditCards.AsReadOnly(); } }
public virtual Cart Cart { get; protected set; }
public virtual void ChangeEmail(string email)
{
if(this.Email != email)
{
this.Email = email;
DomainEvents.Raise<CustomerChangedEmail>(new CustomerChangedEmail() { Customer = this });
}
}
public static Customer Create(string firstname, string lastname, string email, Country country)
{
return Create(Guid.NewGuid(), firstname, lastname, email, country); ;
}
public static Customer Create(Guid id, string firstname, string lastname, string email, Country country)
{
if (string.IsNullOrEmpty(firstname))
throw new ArgumentNullException("firstname");
if (string.IsNullOrEmpty(lastname))
throw new ArgumentNullException("lastname");
if (string.IsNullOrEmpty(email))
throw new ArgumentNullException("email");
if (country == null)
throw new ArgumentNullException("country");
Customer customer = new Customer()
{
Id = id,
FirstName = firstname,
LastName = lastname,
Email = email,
Active = true,
Created = DateTime.Today,
Country = country
};
DomainEvents.Raise<CustomerCreated>(new CustomerCreated() { Customer = customer });
customer.Cart = Cart.Create(customer);
return customer;
}
public virtual ReadOnlyCollection<CreditCard> GetCreditCardsAvailble()
{
return this.creditCards.FindAll
(new CreditCardAvailableSpec(DateTime.Today).IsSatisfiedBy).AsReadOnly();
}
public Nullable<PaymentIssues> IsPayReady()
{
if (this.Balance < 0)
return PaymentIssues.UnpaidBalance;
if (this.GetCreditCardsAvailble().Count == 0)
return PaymentIssues.NoActiveCreditCardAvailable;
return null;
}
public virtual void Add(CreditCard creditCard)
{
this.creditCards.Add(creditCard);
DomainEvents.Raise<CreditCardAdded>(new CreditCardAdded() { CreditCard = creditCard });
}
internal virtual void Add(Purchase purchase)
{
this.purchases.Add(purchase);
}
}
如果我要重构上面的代码,我会改变什么?
- 删除
Purchases
和County
属性。我永远不需要访问County
属性,所以为什么还要引用它?每次加载Customer
时都不会访问购买列表,所以根本没有必要将其放在customer
对象上。是的,你可以使用延迟加载。但为什么要在一个对象中包含你实际上并不需要的属性呢? - 移动和重构
IsPayReady
。IsPayReady
是结账上下文的一部分。我不认为它应该放在customer
对象内部。所以我创建了 CheckoutService,这是一个领域服务,它检查customer
是否可以付款,product
是否可以销售,然后执行purchase
。 - 删除
Cart
属性。Cart
是一个聚合根,可以在需要时直接访问,同样,没有必要每次都与Customer
一起加载它。 - 删除
Created
和Active
属性。除非你的业务分析师说你需要将这两个属性保留在你的领域中,因为它们是业务领域的一部分,否则你不应该保留它们。此外,我已经将 领域事件日志记录 引入了我的代码库,所以根本没有必要用对象生命周期信息来污染你的领域模型,因为这些信息已经包含在事件中了。
现在 Customer
聚合根看起来是这样的
public class Customer : IAggregateRoot
{
private List<CreditCard> creditCards = new List<CreditCard>();
public virtual Guid Id { get; protected set; }
public virtual string FirstName { get; protected set; }
public virtual string LastName { get; protected set; }
public virtual string Email { get; protected set; }
public virtual string Password { get; protected set; }
public virtual decimal Balance { get; protected set; }
public virtual Guid CountryId { get; protected set; }
public virtual ReadOnlyCollection<CreditCard> CreditCards
{ get { return this.creditCards.AsReadOnly(); } }
public virtual void ChangeEmail(string email)
{
if(this.Email != email)
{
this.Email = email;
DomainEvents.Raise<CustomerChangedEmail>(new CustomerChangedEmail() { Customer = this });
}
}
public static Customer Create(string firstname, string lastname, string email, Country country)
{
return Create(Guid.NewGuid(), firstname, lastname, email, country); ;
}
public static Customer Create
(Guid id, string firstname, string lastname, string email, Country country)
{
if (string.IsNullOrEmpty(firstname))
throw new ArgumentNullException("firstname");
if (string.IsNullOrEmpty(lastname))
throw new ArgumentNullException("lastname");
if (string.IsNullOrEmpty(email))
throw new ArgumentNullException("email");
if (country == null)
throw new ArgumentNullException("country");
Customer customer = new Customer()
{
Id = id,
FirstName = firstname,
LastName = lastname,
Email = email,
CountryId = country.Id
};
DomainEvents.Raise<CustomerCreated>(new CustomerCreated() { Customer = customer });
return customer;
}
public virtual ReadOnlyCollection<CreditCard> GetCreditCardsAvailble()
{
return this.creditCards.FindAll
(new CreditCardAvailableSpec(DateTime.Today).IsSatisfiedBy).AsReadOnly();
}
public virtual void Add(CreditCard creditCard)
{
this.creditCards.Add(creditCard);
DomainEvents.Raise<CreditCardAdded>(new CreditCardAdded() { CreditCard = creditCard });
}
}
为什么保持聚合根扁平化是个好主意?
- 更好的性能,因为你加载的数据更少
- 更容易单元测试
- 更易于维护和扩展
- 需要更少的 ORM 配置
- 更小的事务,意味着更小的锁,这意味着更好的吞吐量
为了这篇文章,我已经重构了我 Github 上的 "领域驱动设计示例" 仓库。如果您想查看所有更改,请点击这里。
3. 领域模型 != 读取模型
在领域模型方面,查询可能会很棘手。我记得当有一个需求需要将更多数据投影到 UI 上时。那真是太痛苦了。我不得不扩展我的聚合根并向其添加额外的属性,这违反了最佳实践第 1 和第 2 条。为了提高性能,我花了几个小时调整 NHibernate 配置。这是错误的。我应该只将我的聚合根用于命令,而应该使用数据库视图来投影数据。我之所以这样做,是因为我当时认为使用 DDD 我应该在所有地方都使用领域模型。只需使用 Specification pattern 并遍历模型,问题就解决了,SQL 已死,Specification pattern 是未来的方向。这是天真的。
对于复杂的 API 或 UI 投影,你应该使用 Read
模型。Read
模型并不可怕,它可以很简单,就像数据库视图一样。如果你想看更多示例,请点击这里。
另外,我建议开发者在开始使用 DDD 时,避免使用 Specification Pattern,它弊大于利。
4. 领域事件是不可变的既定事实
许多人并不完全理解领域事件。他们认为领域事件是一个交互式接口。Domain
事件 CustomerCheckedOut
被触发。处理程序接收到它,加载另一个聚合根,而另一个聚合根可以拒绝 CustomerCheckedOut
领域事件。Domain
事件不是一个交互式接口。Domain
事件只是一个不可变的既定事实。Domain
事件处理程序监听事件,所有这些处理程序所能做的就是改变其他聚合根、发送电子邮件等。处理程序不能改变领域事件,也不能改变已触发领域事件的聚合根。你不应该使用 domain
事件来请求执行某事的权限,例如,CanCustomerCheckout
领域事件询问整个系统:这个 customer
可以结账吗?没问题吗?
你什么时候会使用 Domain
Events?当你需要告诉你的 Domain
模型中的其他组件某事已经发生时,这是一个让某个处理程序采取行动的机会。例如,处理程序会发送电子邮件,它会加载一个业务计数器并增加它以进行分析,它会重新同步某些有界上下文的数据。
5. 聚合根不能访问存储库
总的来说,只有应用程序服务、领域服务和领域事件处理程序才能访问你的存储库接口。聚合根不应该访问存储库。