何时定义领域服务

若遵循基于面向对象设计范式的领域驱动设计,并用以应对纷繁复杂的业务逻辑,则强调领域模型的充血设计模型已成为社区不争事实。我将Eric提及的战术设计要素如Entity、Value Object、Domain Service、Aggregate、Repository与Factory视为设计模型。这其中,只有Entity、Value Object和Domain Service才能表达领域逻辑。

为避免贫血模型,在封装领域逻辑时,考虑设计要素的顺序为:

1
Value Object -> Entity -> Domain Service

切记,我们必须将Domain Service作为承担业务逻辑的最后的救命稻草。之所以把Domain Service放在最后,是因为我太清楚领域服务的强大“魔力”了。开发人员总会有一种惰性,很多时候不愿意仔细思考所谓“职责(封装领域逻辑的行为)”的正确履行者,而领域服务恰恰是最便捷的选择。

就我个人的理解,只有满足如下三个特征的领域行为才应该放到领域服务中

  • 领域行为需要多个领域实体参与协作
  • 领域行为与状态无关
  • 领域行为需要与外部资源(尤其是DB)协作

假设某系统的合同管理功能允许客户输入自编码,该自编码需要遵循一定的编码格式。在创建新合同时,客户输入自编码,系统需要检测该自编码是否在已有合同中已经存在。针对该需求,可以提炼出两个领域行为:

  • 验证输入的自编码是否符合业务规则
  • 检查自编码是否重复

在寻找职责的履行者时,我们应首先遵循“信息专家模式”,即“拥有信息的对象就是操作该信息的专家”,因此可以提出一个问题:领域行为要操作的数据由谁拥有?针对第一个领域行为,就是要确认谁拥有自编码格式的验证规则?有两个候选:

  • 拥有自编码信息的“合同(Contract)”对象
  • 体现自编码知识概念自身的“自编码(CustomizedNumber)”对象

我倾向于定义CustomizedNumber值对象,将该检测规则封装其内,并在构造函数中对其进行验证。在领域驱动设计中,值对象往往用于封装这些基础概念。由于自定义的类型可以封装领域行为,就可以有效地实现职责的“分治”,实现对象的协作。

若要检查自编码是否重复,则需要从数据库中查找,这就需要通过Repository与DB协作。基于前面总结的三个特征,则该职责应该分配给一个领域服务,例如DuplicatedNumberChecker。

从职责分配的角度看,实体Contract又或者值对象CustomizedNumber才应该是承担该职责的合理选择。为何我却定义了这么一条例外原则呢?究其原因,就是在领域驱动设计中,我们应尽量保证实体与值对象的纯粹性,尤其不应该依赖于Repository(资源库)。继续深挖根本原因,是因为实体与值对象的生命周期是由Repository管理的。倘若被管理的实体对象还依赖了Repository,就要求该实体对应的Repository在管理实体对象的生命周期的同时,还需要管理它与Repository的依赖,这并不合理。值对象在一个聚合(Aggregate)边界之内,道理相同。

举例来说,假设Contract是聚合根,如果将检查重复编码的职责分配给该实体对象(或值对象CustomizedNumber),内部就需要依赖ContractRepository。然而,Contract的获取也是通过Repository得到,在基础设施层对ContractRepository的实现时,其实并不知道该如何管理二者之间的依赖。如果Contract实体还要依赖其他Repository,就更不可能了。

1
2
3
4
5
public class ContractRepositoryImpl implements ContractRepository {
public Contract contractById(Identity contractId) {
//这里并不知道Contract对象需要注入ContractRepository对象自身
}
}

若真要解决此依赖管理问题,较简单的做法是为Contract提供一个setContractRepository()的依赖注入方法。不过,当Contract是通过Repository来获得时,如Spring、Guice之类的DI框架都无法注入这一依赖,因而需要显式调用,这就会引入对Repository具体实现的耦合。这样的耦合放在领域层,会导致本来单纯的领域层内核依赖了外部资源。倘若将这种具体耦合往外推,例如推到应用层,又会加重调用者的负担。

领域服务则不存在此问题,因为它的生命周期不是由Repository管理。如下的领域服务定义是合情合理的:

1
2
3
4
5
6
7
8
9
public class DuplicatedNumberChecker {

@Repository
private ContractRepository repository;

public boolean isDuplicate(CustomizedNumber number) {
return repository.existsNumber(number);
}
}

我们在分配领域逻辑时,领域服务是最轻易也是最便宜的首选。这会导致领域服务的泛滥,长此以往,对领域层的开发又会走向“贫血模型”的老路。所谓“服务”本身就是一个抽象概念。越抽象就越显得包容并蓄。例如定义一个OrderService,那么所有和订单有关的逻辑都可以往这个服务里面塞,而诸如Order之类的实体对象终归有不少限制,分配职责时需得思虑再三。因此,倘若在设计与开发时对职责的分配不加约束,所谓的“职责分治”就不过是一句空话罢了。

归根结底,主流的领域驱动设计在战术层面考察的其实是面向对象的设计能力。我认为,所谓面向对象设计,核心就是角色职责协作。在分配职责时,应考虑将数据与行为封装在一起,这是面向对象设计的首要原则。

为了避免程序员把领域服务当做一个“筐”,什么逻辑都往里面装,除了需要提高团队成员面向对象的设计能力,并加强代码评审之外,还有一个方法,就是对领域服务加以约束。

没有任何语言可以在DDD设计要素上施加约束。Mat Wall与Nik Silver在对Guardian.co.uk网站推行DDD时的实践值得我们借鉴。他们在文章《演进架构中的领域驱动设计》中建议:

为了对付这一行为,我们对应用中的所有服务进行了代码评审,并进行重构,将逻辑移到适当的领域对象中。我们还制定了一个新的规则:任何服务对象在其名称中必须包含一个动词。这一简单的规则阻止了开发人员去创建类似于ArticleService的类。取而代之,我们创建 ArticlePublishingService和ArticleDeletionService这样的类。推动这一简单的命名规范的确帮助我们将领域逻辑移到了正确的地方,但我们仍要求对服务进行定期的代码评审,以确保我们在正轨上,以及对领域的建模接近于实际的业务观点。

其实,这一别具一格的约束形式其实与服务的本质是一脉相承的,即服务应代表无状态的领域行为,甚至可以说领域服务是领域层面用例的体现。

这一实践可能会导致更多细粒度的领域服务产生,但更有可能的结果是,当我们在创建一个新的领域服务时,可能会考虑暂时停下来,想一想,要分配给这个新服务的领域逻辑是否有更好的去处呢?即使因为该逻辑可能牵涉到多个领域实体,又或者需要与Repository协作而不得不放入到领域服务中,似乎也可以考虑将领域逻辑中与实体(或值对象)数据强相关的内容”摘“出来,分配到合适的地方,保证职责分配的合理均衡。和谐的协作机制是好的面向对象设计。