领域模型
实体与聚合根
读者和图书是实体;由于每个读者都将有自己的借书信息(比如,什么时候借的哪本书,是否已经归还,或者是否已经过期),而与之对应地,每本书也可以有被借历史(比如,这本书是什么时候借给哪个读者),于是,借书信息也是实体。
再来看看聚合。借书信息是与读者和图书关联的,也就是说,没有读者,借书信息没有存在的意义,同样,没有图书,借书信息也同样不存在。每个读者可以没有任何借书信息(或者说借书记录),也可以有多条借书信息;而每本书也同样可以没有任何被借信息(或者说被借记录),也可以有多条被借记录。因此存在两个聚合:读者-借书信息聚合(1..0.)以及图书-借书信息聚合(1..0.)。读者和图书分别为聚合根,借书信息为实体。与Tiny Library对应起来,总结如下:
-
读者:Reader,聚合根
-
图书:Book,聚合根
-
借书信息:Registration,实体
根据上述描述,我们可以确定,我们将来需要针对读者(Reader)和图书(Book)实现仓储以及相应的规约。 -
Reader聚合根
public partial class Reader : IAggregateRoot
{
}
- Book聚合根
public partial class Book : IAggregateRoot
{
}
- Registration实体
public partial class Registration : IEntity
{
}
聚合根、实体、值对象的关系
实体有ID,有生命周期,有状态(用值对象描述状态),实体通过ID进行区分是这个实体还是那个实体;
聚合根是实体,聚合根的ID全局唯一,聚合根下面的实体的ID在聚合根内唯一即可;
值对象的核心意思是值,与是否是复杂类型无关,比如下图中的Price、Count、OrderNo、CustomerAddress都是值对象;
值对象无生命周期,它的本质是一个值,通过两个值对象的值是否相同来区分是同一个值对象;
值对象用于描述实体的状态;
using System.Collections.Generic;
namespace Rsdf.Net.Boilerplate.Domain.Entities
{
// 聚合根
public class Order
{
public string Id; // 值对象,订单的ID,全局唯一
public string OrderNo; // 值对象
public Address CustomerAddress; // 值对象
public IList<OrderItem> Items; // 实体集合
}
// 实体
public class Address
{
public string ProductId; // 实体的主键,Order内唯一即可
public string ProductName; // 值对象
public float Price; // 值对象
public int Count; // 值对象
}
// 值对象
public class OrderItem
{
public string Province; // 值对象
public string City; // 值对象
public string County; // 值对象
}
}
实体
有时,实体并不见得是一种适当的建模工具,而我们对实体的使用也有可能是不恰当的。很多时候,一个领域概念应该建模成值对象,而不是实体对象。
由于只从数据出发,CRUD系统是不能创建出好的业务模型的。
值对象可以用于存放实体的唯一标识。值对象是不变(immutable)的,这可以保证实体身份的稳定性,并且与身份标识相关的行为也可以得到集中处理。
添加业务逻辑
根据DDD,实体是能够处理业务逻辑的,应该尽量将业务体现在实体上;如果某些业务牵涉到多个实体,无法将其归结到某个实体的话,就需要引入领域服务(Domain Service)。案例业务简单,目前不会涉及到领域服务,因此,在本案例中,业务逻辑都是在实体上处理的。
以读者(Reader)为例,它有借书和还书的行为,我们将这两种行为实现如下:
Reader中的业务逻辑
public partial class Reader : IAggregateRoot
{
public void Borrow(Book book)
{
if (book.Lent)
throw new InvalidOperationException("The book has been lent.");
Registration reg = new Registration();
reg.RegistrationStatus = RegistrationStatus.Normal;
reg.Book = book;
reg.Date = DateTime.Now;
reg.DueDate = reg.Date.AddDays(90);
reg.ReturnDate = DateTime.MaxValue;
book.Registrations.Add(reg);
book.Lent = true;
this.Registrations.Add(reg);
}
public void Return(Book book)
{
if (!book.Lent)
throw new InvalidOperationException("The book has not been lent.");
var q = from r in this.Registrations
where r.Book.Id.Equals(book.Id) &&
r.RegistrationStatus == RegistrationStatus.Normal
select r;
if (q.Count() > 0)
{
var reg = q.First();
if (reg.Expired)
{
// TODO: Reader should pay for the expiration.
}
reg.ReturnDate = DateTime.Now;
reg.RegistrationStatus = RegistrationStatus.Returned;
book.Lent = false;
}
else
throw new InvalidOperationException(string.Format("Reader {0} didn't borrow this book.",
this.Name));
}
}
仓储
在领域驱动设计的案例中,仓储的设计是很具有争议性的话题,因为仓储这个角色本身就与领域模型和基础结构层对象相关,它需要序列化领域对象(应该说是聚合),然后将其保存到基础结构层的持久化机制。于是,在领域驱动设计的社区中,存在两种观点:
-
领域模型不能访问仓储,理由是:仓储需要跟技术架构层打交道,在领域模型中访问仓储就会破坏领域模型的纯净度。需要使用仓储的,需要在领域模型上加上一层,比如Application层,在该层中获取仓储实例并处理持久化逻辑
-
领域模型可以访问仓储,但仅仅是通过仓储接口和IoC容器访问仓储;仓储的具体实现通过IoC注入到领域模型中
其实,这只不过是个人习惯问题,我认为两种方法都可以接受,具体采用哪种方法,就要看具体项目的需求和实现情况而定。在Tiny Library中,由于业务简单,所以采取的是第一种方式,但上述的理由并不充分,换句话说,出于业务需求,我采用了第一种方式,但并不是因为仓储需要跟技术架构层打交道所以才把对仓储的访问放在Application层中。仓储也是领域模型的一部分,领域模型依赖于仓储的抽象。
网友评论