原创

领域驱动设计


领域驱动设计

领域驱动设计的要点:

  1. 领域模型的概念和实现密不可分; 设计过程和开发过程密不可分.
  2. 领域模型随时间演进
  3. 领域模型是一种通用语言

应用程序的复杂性并不在技术上, 而是来自领域本身, 用户的活动或业务. 有两个前提:

  1. 在大多数软件项目中, 主要的焦点应该是领域和领域逻辑
  2. 复杂的领域设计应该基于模型

领域驱动设计是一种思维方式, 也是一组优先任务, 旨在加速必须处理复杂领域的软件项目的开发.

本书主要面向敏捷开发过程体系, 遵循两个开发实践:

  1. 迭代开发, 使用极限编程(XP)为基础讨论设计和过程的交互.
  2. 开发人员与领域专家具有密切的联系, 开发人员和领域专家的协作必须贯穿整个项目的生命周期.

极限编程(Extreme Programming, 简称XP)是一种软件开发方法论, 它强调通过一系列实践来提高软件开发的质量和响应性. XP 由 Kent Beck 在 1990 年代末提出, 主要目标是通过频繁的反馈和持续的改进来提高软件项目的成功率.

以下是极限编程的一些核心实践:

  1. 测试驱动开发(TDD): 开发人员首先编写测试用例, 然后编写能够通过这些测试的代码. 这种方法可以确保代码的质量和可维护性.

  2. 持续集成(CI): 开发人员频繁地将代码集成到共享存储库中, 通常每天多次. 每次集成后都会进行自动化测试, 以确保新代码不会破坏现有功能.

  3. 重构: 定期对代码进行重构, 以改进其结构和设计, 而不改变其功能. 这有助于保持代码的清晰性和可维护性.

  4. 结对编程(Pair Programming): 两名开发人员共同在一台计算机上编写代码, 一人负责编写, 另一人负责审查和提供反馈. 这种合作方式可以提高代码质量并促进知识共享.

  5. 简单设计: 尽量保持设计的简单性, 只实现当前所需的功能, 避免过度设计. 这有助于减少复杂性和开发时间.

  6. 客户协作: 与客户紧密合作, 确保开发的功能符合客户的需求和期望. 客户参与需求定义和验收测试过程.

  7. 可持续的开发节奏: 开发团队以可持续的速度工作, 避免过度劳累和压力, 以保持长期的生产力和质量.

极限编程强调团队合作、沟通和反馈, 旨在通过这些实践来提高软件开发的效率和质量.

运用领域模型

为了创建真正能为用户活动所用的软件, 开发团队必须运用一整套与这些活动相关的知识体系, 其所需知识的广度令人望而生畏, 其拥有庞大且复杂的信息, 模型这种知识形式对知识进行了选择性的简化有意的结构化.

领域模型是领域知识所传达的思想, 而且是对知识有严格的组织和有选择的抽象, 不单单是为了实现某种目的而构造出来的软件机制, 更是出于某种目的而概括地反映现实.

模型在领域驱动设计中的基本用途:

  1. 模型和设计的核心相互影响, 模型和实现之间的紧密联系才能使模型变得有用, 并确保模型中的分析能够转化为最终产品.
  2. 模型是团队所有成员使用的通用语言的中枢.
  3. 模型是浓缩的知识, 是团队一致认同的领域知识的组织方式和重要元素的区分方式.

软件的核心是为用户解决领域相关问题的能力, 所有其他的特性都要服务于这个目的. 开发人员必须钻研领域以获取业务知识, 必须磨砺其建模技巧, 并精通领域设计. 然而大多数的开发人员对学习领域相关的知识不感兴趣, 不会下力气扩展自己的领域建模技巧, 而是关注于能够提高其技能的可量化问题, 试图用技术来解决领域问题.

开发人员创建一个克服领域复杂性的易懂模型会带来巨大的成就感, 可以采用一些系统性的思考方法来透彻地理解领域并开发出有效的模型.

消化知识

具体的原型使得领域专家更清楚理解模型的含义, 并能够让他们看到将新学到的知识融合到模型中, 反映到软件上. 他们也可以从原型中得到具体的反馈, 从而印证自己的想法.

模型相当于一个框架, 开发人员可以借助它来组织新的信息并更快地学习, 从而更准确地判断哪些部分重要, 哪些部分不重要, 更好地与领域专家进行沟通.

当提出新的功能需求时, 可以走查模型对象交互的场景. 当模型对象无法清楚地表达某个重要场景时, 就通过头脑风暴活动创建新的模型对象或修改原有的模型对象, 并消化理解模型对象中的知识.

有效建模的要素

模型和实现的绑定: 最初的原型虽然简陋, 但在模型和实现之间建立了早期链接, 在所有的后续的迭代中一直在维护.

建立一种基于模型的语言: 开发人员和领域专家能够直接使用模型中的术语, 并组织为符合模型结构的语句, 而且无需反映即相互理解要表达的意思.

开发一个蕴含丰富知识的模型: 对象具有行为和强制性规则. 模型不仅仅是一种数据模式, 还是解决复杂问题不可或缺的部分. 模型包含各种类型的知识.

提炼模型: 模型日趋完整的过程中, 重要的概念不断被添加, 不再使用的或不重要的概念则从模型中移除.

头脑风暴和实验: 头脑风暴和大量实验的创造力才使找到一个富含知识的模型并对其提炼. 这个过程中, 基于模型的语言提供了很大帮助, 而且贯穿整个实现过程中的反馈闭环对模型起到了训练作用. 这种知识消化将团队的知识转化为有价值的模型.

知识消化

高效的领域建模人员说知识的消化者, 他们在大量信息中探寻有用的部分, 不断尝试各种信息的组织方式, 找到一组适用于所有细节的抽象概念.

知识消化并非一项孤立的活动, 一般是在开发人员的领导下, 由开发人员与领域专家组成的团队来共同协作. 他们共同收集信息并通过消化组成为有用的形式. 信息的原始资料来自领域专家头脑中的实现, 现有系统的用户, 以及技术团队积累的相关经验.

传统的瀑布方法中, 知识只是朝一个方向流动, 创建的模型只是基于业务专家的意见, 没有向程序员学习的机会, 也得不到早期软件版本的经验.

有些项目使用了迭代过程, 但由于没有对知识进行抽象而无法建立知识体系. 开发人员需要对领域有充分的了解, 可使项目从原有的特性中扩展出强大的新特性.

建模时如果只有技术人员, 没有领域专家的协作, 那么得到的概念则是幼稚的, 无法反映出领域专家的思考方式.

团队所有成员需要一起消化理解模型, 领域模型的不断精化迫使开发人员学习重要的业务原理, 而不是机械地进行功能的开发. 领域专家被迫提炼自己已知道的重要知识并完善其自身的理解, 而且会渐渐理解软件项目所必需的概念严谨性.

开发人员将自己的知识输入到模型中, 因此模型的组织更严密, 抽象更整洁, 为实现提供了更大支持. 同时由于领域专家的知识输入, 模型反映了业务的深层次知识, 使得真正的业务原则得以抽象.

模型在不断改进的同时, 也是组织项目信息流的工具. 模型聚焦于需求分析, 与编程和设计紧密交互, 通过良性循环加深团队成员对领域的理解, 使他们能对模型进一步精化.

模型永远不会是完美的, 是一个不断演化完善的过程. 模型对理解领域必须是切实可用的, 必须非常精确, 以便应用程序易于实现和理解.

持续学习

高效率的团队需要有意识地积累知识, 并持续学习. 对于开发人员, 既要完善技术知识, 也要培养一般的建模技巧, 并且认真学习所从事的特定领域的知识. 善于自学的团队成员会成为团队的中坚力量, 涉及最关键领域的开发任务需要他们来攻克.

知识丰富的设计

知识消化所产生的模型能够反映出对知识的深层理解. 在模型发生改变时, 开发人员对实现进行重构, 以便反映出模型的变化, 这样新知识就被合并到应用程序中.

业务活动和规则, 与实体一样都是领域的核心. 当建模不再局限于寻找实体和值对象时, 才能充分吸取知识, 因为业务规则之间可能会存在不一致. 开发人员通过与领域专家的紧密协作来消化知识的过程才使得规则得以澄清和充实, 并消除规则之间的矛盾以及删除一些无用规则.

为了更明确的设计, 程序员和相关人员都必须理解业务活动的本质, 明白它是一个明确且重要的业务规则, 而不只是一个不起眼的方法或函数. 程序员可以向业务专家展示技术工件甚至时代码, 但应该是领域专家可理解的, 以便形成反馈闭环.

深层模型

有用的模型很少停留在表面, 随着对领域和应用程序需求的理解逐步加深, 往往会丢弃最初看起来很重要的表面元素或者切换它们的角度. 这时, 一些开始不可能发现的, 恰恰切中问题要害的巧妙抽象就会渐渐浮出水面.

知识消化是一种探索, 永无止境.

交流与语言的使用

领域模型可成为软件项目通用语言的核心, 模型反映了领域深层含义的术语和关系. 这些术语和相互关系提供了模型语言的语义, 讲模型和开发活动结合在一起, 并使模型与代码紧密绑定的.

基于模型的交流不仅仅局限于UML图, 可以充分利用各种交流手段最有效地使用模型.

模式: Ubiquitous Language

由于开发人员的术语和领域专家的术语存在鸿沟, 造成对项目业务的模糊描述和认识. 即使进行翻译——间接沟通(存在误解的风险)会造成模型概念混乱, 从而导致有害的代码重构.

日常讨论所使用的术语和代码中使用的术语不一致, 导致对领域的深刻表述消纵即逝, 根本无法记录到代码或文档中. 翻译使得沟通不畅, 并削弱了知识消化. 任何一方的语言都不能成为公共语言, 因为都无法满足所有的需求.

项目需要一种公共语言, 经过团队的一致努力, 领域模型可以成为这种公共语言的核心, 同时将团队沟通与软件实现紧密联系到一起.

Ubiquitous Language通用语言的词汇包括类和主要操作的名称. 语言中的术语有已经明确的规则, 施加于模型上的高级组织原则和常用于领域模型的模式名称. 模型之间的关系成为所有语言都具有的组合规则, 词和短语的意义反映了模型的语义.

开发人员使用基于模型的语言来描述系统中的工件, 任务和功能, 而领域专家使用这种语言来讨论需求, 开发计划和特性. 语言使用得越普遍, 理解进行得就越流畅.

能够产生更有用的知识消化过程依赖基于模型的语言, 持续使用Ubiquitous Language通用语言可以暴露模型中存在的缺点, 团队就可以尝试替换或引入术语. 在语言上更改也会在领域模型中引起相应的更改, 并促使团队更新类图并重命名代码中的类和方法.

大量使用基于模型的语言, 达到沟通流畅的程度, 可以逐步得到一个完整的, 易于理解的模型, 其由简单元素组成, 并通过简单元素的组合表达复杂的概念.

将模型作为语言的支柱. 确保团队在内部的所有交流中以及代码中坚持使用这种语言, 甚至在画图, 写东西, 讲话时也要坚持使用.

通过尝试不同的表示方法来消除难点, 然后重构代码, 重新命名类, 方法和模块, 以便与新模型保持一致, 并解决交谈中的术语混淆问题, 形成一致的理解.

对Ubiquitous Language通用语言的更改是对模型的更改.

领域专家应该抵制不合适或无法充分表达领域理解的术语或结构, 开发人员应该密切关注会妨碍设计的有歧义和不一致的地方.

大声地建模

改善模型的最佳方式之一是通过对话来研究, 试着大声说出可能的模型变化中的各种结构, 这样不完善的地方很容易被听出来.

我们的大脑很擅长处理口语的复杂性. 在讨论中使用领域模型的Ubiquitous Language通用语言一起推敲场景和需求时, 通用语言的使用会越来越流利, 而且还能相互指点一些细微的差别, 自然而然地共享了所说的语言.

讨论系统时需要结合模型. 使用模型元素及其交互来大声描述场景, 并且按照模型允许的方式将各种概念结合到一起. 找到更简单的表达方式来讲出你要讲的话, 然后将这些新的想法应用到图和代码中.

一个团队, 一种语言

领域专家使用Ubiquitous Language通用语言互相讨论, 或者与开发人员进行讨论时, 很快会发现模型中哪些地方不符合他们的需要, 甚至是错误的. 在开发人员的帮助下, 模型语言的精确性也会促使领域专家发现他们想法中的矛盾和含糊之处.

开发人员和领域专家可以通过一步步使用模型对象来走查场景, 从而对模型进行非正式的测试, 每次讨论都是开发人员和领域专家一起使用模型的机会, 加深对领域模型的理解, 并对领域概念精化.

领域专家可以使用模型语言来编写用例, 甚至可以直接利用模型来具体说明验收测试.

有了Ubiquitous Language通用语言, 开发人员之间的对话, 领域专家之间的讨论以及代码本身所表达的内容都基于同一种与语言, 都来自一个共享的领域模型.

文档和图

人是天生的视觉动物, 图可以帮助人们掌握某些类型的信息. 图是一种沟通和解释手段, 可以促进头脑风暴, 简洁的小图能够很好的实现这些目标, 而涵盖整个对象模型的综合性大图反而失去了沟通或解释的能力, 因为它们将人淹没在大量细节之中, 而且这些图也缺乏目的性.

应避免使用包罗万象的对象模型图, 应该使用简化的图, 图中只包含对象模型的重要概念.

设计的重要细节应该在代码中体现出来. 良好的实现应该是透明的, 清楚地展示其背后的模型. 互为补充的图和文档能够引导人们将注意力放在核心要点上. 通常的用法是以图为主, 辅以文本注释; 但更推荐以文本为主, 用精心挑选的简化图作为说明.

务必记住模型不是图. 图的目的是帮助表达和解释模型. 代码可以充当设计细节的存储库.

书面设计文档

口头交流可以解释代码的含义, 因此可作为代码精确性和细节的补充, 但书面文档也是必不可少的, 任何规模的团队需要它来提供稳定和共享的交流.

但要想编写出能够帮助团队开发出好软件的书面文档是不小的挑战, 且文档的形式变得一成不变, 往往会从项目进展流畅中脱离出来, 跟不上代码或项目语言的演变.

两条用于评估文档的总体原则:

  1. 文档应作为代码和口头交流的补充.

    极限编程主张完全不使用多余的设计文档, 而让代码解释自己, 因为运行代码所产生的行为是明确的. 依赖代码作为交流媒介可以促使开发人员保持代码的整洁和透明.

    代码作为设计文档也有局限性, 尽管代码的行为是非常明确的, 但不意味着其行为是显而易见的.

    文档不应再重复表示代码已经明确表达出的内容, 文档应该着重说明含义, 以便人们能够深入理解大尺度结构, 并将注意力集中在核心元素上. 当编程语言无法直接明了地实现概念时, 文档可以澄清设计意图.

  2. 文档应当鲜活并保持最新.

    设计文档的最大价值在于解释模型的概念, 帮助在代码的细节中指引方向.

    文档必须深入到各种项目活动中, 通过观察文档与Ubiquitous Language通用语言之间的交互判断是否做到这一点.

    如果设计文档中使用的术语不再出现在讨论和代码中, 那么文档就没有起到它的作用. 如果文档对Ubiquitous Language通用语言没有影响, 那么文档一定出问题了.

    Ubiquitous Language通用语言随着时间自然地改变, 而文档渐渐过时, 此时可以将过时的文档安全归档, 如果继续使用可能会产生混淆并损害项目. 如果文档不再担负重要的作用, 纯粹靠意志和纪律保持其更新就是浪费精力.

    Ubiquitous Language通用语言可以使其他文档更简洁和明确, 当领域模型反映了与业务最相关的知识时, 应用程序的需求成为该模型内部的场景, 而Ubiquitous Language通用语言可以直接使用模型驱动设计的方式描述此类场景. 结果就是规格说明文档的编写更简单, 因为不必传达模型背后隐含的业务知识.

通过将文档减至最少, 并主要用它来补充代码和口头交流, 可以避免文档与项目脱节. 根据Ubiquitous Language通用语言及其演变来选择那些需要保持更新并与项目活动紧密交互的文档.

完全依赖可执行代码的情况

良好的代码具有很强的表达能力, 但它所传递的信息不能确保是准确的. 代码的方法名称可能有歧义, 会产生误导或者因为过时无法表示方法的本质含义. 测试中的断言是严格的, 但变量和代码组织方式所表达出来的意识未必严格.

消除这些差异除了良好的编程风格(靠开发人员的自律), 还可以使用声明式设计.

解释性模型

模型在帮助领域学习方面具有很大的价值, 对设计起到推动作用的模型是领域的一个试图, 但为了学习领域, 可以引入只用作传递一般领域知识教学工具的实图.

解释性模型提供了一定的自由度, 可以专门为某个特定主题定制一些表达力更强的风格, 还可以以一种不同的方式来呈现领域, 并且各种不同角度的解释有助于人们更好地学习.

解释性模型不必且最好不是对象模型, 虽然和领域驱动设计的模型有对应关系, 但并不完全类似.

绑定模型和实现

领域模型必须能直接帮助开发可运行的软件.

模式: Model-Driven Design

严格按照基础模型来编写代码, 能够使代码更好地表达设计含义, 并使模型与实际的系统相契合.

很多复杂项目尝试使用领域模型, 但并没有把代码的编写与模型紧密联系起来, 随着项目的进展, 模型与项目渐行渐远甚至还会起误导作用, 无法保证程序设计的正确性.

Wrong: 很多设计方法都提倡使用完全脱离于程序设计的分析模型, 其是对业务领域进行分析的结果. 分析组织领域中的概念时, 完全不会考虑其在软件系统中的作用. 分析模型仅仅是理解工具, 在创建分析模型时没有考虑程序设计的问题, 分析模型很有可能无法满足程序设计的需求.

分析模型在编码开始后, 开发人员不得不重新对设计进行抽象, 大部分的领域知识会被丢弃. 纯粹的分析模型难以实现理解领域, 因为在程序设计和实现过程中总是会发现一些关键的知识点, 而细节问题则会层出不穷(深有领会).

如果整个程序设计或者核心部分没有与领域模型相对应, 那么这个模型是没有价值的, 软件的正确性也值得怀疑. 模型和设计功能之间过于复杂的对应关系也是难于理解的, 在实际项目中, 当设计改变时也无法维护这种关系. 若分析与设计之间产生严重分歧, 那么在分析和设计活动中所获得的知识就无法彼此共享.

分析工作一定要抓住领域内的基础概念, 并且用易于理解和易于表达的方式描述出来. 设计工作则需要指定一套可以由项目中使用的编程工具创建的组件, 使项目可以在目标部署环境中高效运行, 并且能够正确解决应用程序所遇到的问题.

Model-Driven Design模型驱动设计不再将分析模型和程序设计分离, 而是寻求一种能够满足这两方面需求的单一模型.

绑定模型和程序设计是切实可行的. 如果模型对于程序的实现不太适用时, 需要重新设计它. 如果模型无法准确地描述领域概念时, 也需要重新设计它. 建模和程序设计结合为一个统一的迭代开发过程.

软件系统各部分的设计应该准确地反映领域模型. 应该反复检查并修改模型, 以便软件可更加自然地实现模型, 让模型反映出更深层次的领域概念. 模型不但应该满足设计和实现的两种要求, 而且能够支持Ubiquitous Language通用语言.

从模型中获取用于程序设计和基本职责分配的术语, 让程序代码成为模型的表达, 代码的改变对应模型的改变.

软件开发是一个不断精化模型, 设计和代码的统一过程.

建模范式和工具支持

为了使Model-Driven Design领域驱动设计发挥作用, 一定要在可控范围内严格保证模型与设计之间的一致性, 要实现这种一致性, 必须要运用由软件工具支持的建模范式, 可在程序中直接创建模型中的对应概念.

面向对象编程功能强大, 基于建模范式, 且为建模构造提供了实现方式. Java和许多其他工具都允许创建直接反映概念对象模型的对象和关系.

Prolog语言虽然不被广泛使用, 但却非常适合Model-Driven Design领域驱动设计. 在Model-Driven Design领域驱动设计中, 建模范式是逻辑的, 而模型则是一组逻辑规则以及这些规则所操纵的事实.

C语言并不适用于Model-Driven Design领域驱动设计, 因为没有适用于纯粹过程语言的建模范式. Fortran程序可用来实现数学模型, 但是对于超越函数的更高层次的意义就没有办法了. 大部分非数学领域都不适合用过程语言来进行Model-Driven Design领域驱动设计, 因为这些领域无法被抽象成数学函数或者过程中的操作步骤.

面向对象设计是目前大多数项目所使用的建模范式.

揭示主旨: 为什么模型对用户至关重要

如果程序设计基于一个能够反映用户与领域专家所关心的基本问题的模型, 那么这种设计可以将其主旨更明确地展示给用户. 让用户了解模型, 将使它们有更多的机会挖掘软件的潜能, 也能使软件的行为合乎情理, 前后一致.

模式: Hands-on Modeler

软件开发就是设计. 虽然开发团队中的每个成员都有自己的职责, 但是将分析, 建模, 设计和编程工作过度分离会对Model-Driven Design产生不良影响.

如果编写代码的人员认为自己没必要对模型负责. 或者不知道如何让模型为应用程序服务, 那么这个模型和程序没有任何关联.

如果开发人员没有意识到改变代码意味着改变模型, 那么他们对程序的重构不但不会增强模型的作用, 反而还会削弱效果.

如果建模人员不参与到程序实现的过程中, 那么对程序实现的约束就没有切身的感受, 即使有, 也会很快忘记.

Model-Driven Design领域驱动设计的两个基本要素(模型要支持有效的实现和抽象出关键的领域知识)已经失去一个, 模型最终变得不再实用.

如果分工阻断了设计人员与开发人员之间的协作, 使他们无法传达Model-Driven Design领域驱动设计的种种细节, 那么经验丰富的设计人员则不能将自己的知识和技术传递给开发人员.

Hands-on Modeler亲身实践的建模者并不意味着团队成员不能有自己的专业角色, 但如果把Model-Driven Design领域驱动设计中密切相关的建模和实现两个过程分离开, 则会产生问题.

整体设计的有效性的影响因素有细粒度的设计和实现决策的质量和一致性. 在Model-Driven Design领域驱动设计中, 代码是模型的表达, 改变代码就改变了相应的模型; 程序员就是建模人员.

任何参与建模的技术人员, 不管在项目中的主要职责是什么, 都必须花时间了解代码. 任何负责修改代码的人员则必须学会用代码来表达模型. 每一个开发人员都必须不同程度地参与模型讨论并与领域专家保持联系. 参与不同工作的人都必须有意识地通过Ubiquitous Language通用语言与接触代码的人及时交换关于模型的想法.

总结

Model-Driven Design领域驱动设计利用模型来为应用程序解决问题.

项目组通过知识消化将大量杂乱无章的信息提炼成实用的模型.

Model-Driven Design领域驱动设计将模型和程序实现过程紧密结合.

Ubiquitous Language通用语言则成为开发人员, 领域专家和软件产品之间传递信息的渠道.

模型驱动设计的构造块

领域模型中各个元素的实际设计和实现相对系统化. 将领域设计与软件系统中的其他关注点分离会使设计与模型之间的关系非常清晰. 根据不同的特征来定义模型元素则会使元素的意义更加明确. 对每个元素使用已验证的模式有助于创建出更易于实现的模型.

分离领域

在软件中, 专门用于解决领域问题的部分通常只占系统的很小一部分, 但十分重要. 需要将领域对象与系统中的其他功能分离, 这样能够避免将领域概念与软件技术相关的概念搞混, 也不会在纷繁冗杂的系统中丢失了领域.

模式: Layered Architecture

在面向对象的程序中, 常常会在业务对象中直接写入用户界面, 数据库访问等支持代码. 而一些业务逻辑则会被嵌入到用户界面组件和数据库脚步中. 如果与领域相关的代码分散在大量的其他代码中, 那么查看和分析领域代码会变得异常困难. 对用户界面的修改可能会改变业务逻辑, 而要想调整业务规则可能需要对用户界面代码, 数据库操作代码和其他程序元素进行仔细的筛查. 这样就不可能实现一致的, 模型驱动的对象, 同时也会给自动化测试带来困难.

要想创建出处理复杂任务的程序, 需要做到关注点分离, 同时要维持系统内部复杂的交互关系.

根据软件行业的经验和惯例, 普遍采用Layered Architecture分层架构. 分层的价值在于每一层都只代表程序中的某一特定方面, 这种设计更具内聚性, 更容易理解.

常见的架构分层有:

名称说明
用户界面层(表示层)负责向用户(客户端)显示信息和解释用户指令.
应用层定义软件要完成的任务, 并且指挥表达领域概念的对象来解决问题. 应用层尽量简单, 不包含业务规则或者知识, 只为下一层中的领域对象协调任务, 分配工作, 使它们相互协作.
领域层(模型层)负责表达业务概念, 业务状态信息以及业务规则. 尽管保存业务状态的技术细节是由基础设施层实现的, 但是反映业务情况的状态是由本层控制并使用的. 领域层是业务软件的核心.
基础设施层为上面各层提供通用的技术能力. 为应用层传递消息, 为领域层提供持久化机制, 为用户界面层绘制组件等等. 基础设施层还能通过架构框架来支持4个层次间的交互模式.

将领域层分离出来才是实现Model-Driven Design领域驱动设计的关键.

给复杂的应用程序划分层次. 在每一层分别进行设计, 使其具有内聚性并且只依赖于它的下层. 采用标准的架构模式, 只与上层进行松散的耦合.

将所有与领域模型相关的代码放在一个层中, 并把其他层的代码分开. 领域对象应该将重点放在如何表达领域模型上, 而不需要考虑自己的显示和存储问题, 也无需管理应用任务等内容. 这样使得模型的含义足够丰富, 结构足够清晰, 可以捕捉到基本的业务知识, 并有效地使用这些知识.

将各层关联起来

各层之间是松散连接的, 层与层的依赖关系只能是单向的.

上层可以直接使用或操作下层元素, 方法是通过调用下层元素的公共接口, 保持对下层元素的引用, 以及采用常规的交互手段.

如果下层元素需要与上层元素进行通信, 则需要使用架构模式来连接上下层, 如回调模式或观察者模式.

将用户界面层与应用层和领域层相连的模式是Model-View-Controller模型视图控制器MVC模式. 还有许多其他连接用户界面层和应用层的方式, 只要连接方式能够维持领域层的独立性, 保证在设计领域对象时不需要同时考虑可能与其交互的用户界面, 那么这些连接方式都是可用的.

应用层和领域层可以调用基础设施层所提供的Service. Service的范围选择合理, 接口设计完善, 通过把详细设计封装到服务接口中, 调用程序可保持与Service的松散连接, 并且自身也会很简单.

架构框架

最好的架构框架既能解决复杂技术问题, 也能让领域开发人员集中精力去表达模型. 然而框架很容易为项目制造障碍, 要么设定了太多的假设, 减小了领域设计的可选范围; 要么需要实现太多的东西, 影响开发进度.

当使用框架时, 项目团队应该明确使用目的: 建立一种可以表达领域模型的实现并且用它来解决重要问题. 选择性地运用框架解决难点问题, 可以避开框架的许多不足之处. 明智且审慎地选择框架中最具价值的功能能够减少程序实现和框架之间的耦合, 使随后的设计决策更加灵活.

领域层时模型的精髓

领域模型时一系列概念的集合. 领域层则是领域模型以及所有与其相关的设计元素的表现, 它由业务逻辑的设计和实现组成. 在Model-Driven Design中领域层的软件构造反映出了模型概念.

将领域实现独立出来时领域驱动设计的前提.

模式: The Smart UI反模式

Smart UI智能用户界面是一种设计方法, 与领域驱动设计方法迥然不同且互不兼容.

领域驱动设计只有应用在大型项目上才能产生最大的收益, 而也确实需要高超的技巧. 不是所有的项目都是大型项目, 不是所有的项目团队都能掌握技巧.

当一个项目只需要提供简单的功能, 业务规则较少, 则可以考虑使用Smart UI智能用户界面设计. 在用户界面中实现所有的业务逻辑. 将应用程序分成小的功能模块, 分别将它们实现成用户界面, 并在其中嵌入业务规则. 用关系数据库作为共享的数据存储库. 使用自动化程度最高的用户界面创建工具和可视化编程工具.

项目团队需要有意识的选择设计方式. 常犯的错误有: 采用了一种复杂的设计方法, 却无法保证项目从头到尾始终使用它; 为项目构建一种复杂的基础设施以及使用工业级的工具, 而简单的项目根本不需要它.

使用Smart UI智能用户界面后, 除非重写全部的应用模块, 否则不能该用其他的设计方法. 采用Model-Driven Design领域驱动设计的项目团队从项目初始就应该采用模型驱动的设计, 而且要分离出独立的领域层, 否则很有可能项目到最后变为Smart UI智能用户界面模式了.

其他的开发风格各有用武之地, 但必须考虑到各种对于复杂度和灵活性的限制, 在某些条件下将领域设计与其他部分混在一起会产生灾难性的后果.

其他分离方式

除了基础设施和用户界面之外, 还有其他的因素破坏领域模型. 如必须考虑那些完全没有集成到模型中的领域元素; 不得不与同一领域中使用不同模型的其他开发团队合作等等. 这些会让你的模型结构不再清晰, 并且影响模型的使用效率.

这些问题会在后面章节讨论.

软件中所表示的模型

表示模型的三种模型元素模式: Entity, Value Object和Service.

定义用来捕获领域概念的对象很容易, 但要想反映其含义却很困难. 这要求我们明确区分各种模型元素的含义, 并与一系列设计实践结合起来, 从而开发出特定类型的对象.

Entity和Value Object的区别: 一个对象是用来表示某种具有连续性和标识的事物, 还是用于描述某种状态的属性.

领域中有一些方面适合用动作或操作来表示, 最好用Service来表示, 不应把操作的责任强加到Entity和Value Object上. Service是应客户端请求来完成某事. 对软件要做的某项无状态的活动进行建模时, 可以将该活动作为一项Service.

每个设计决策都应该是在深入理解领域中的某些深层知识后做出的. 高内聚, 低耦合可应用于概念本身. 在Model-Driven Design中, Module是模型的一部分, 应该反映领域中的概念.

关联

对象之间的关联使得建模与实现之间的交互更为复杂. 模型中每个可遍历的关联, 软件中都要有同样属性的机制.

设计必须指定一种具体的遍历机制, 遍历的行为应该与模型中的关联一致.

有三种方法可使得关联更易于控制:

  1. 规定一个遍历方向.
  2. 添加一个限定符, 以便有效地减少多重关联.
  3. 消除不必要的关联.

尽可能对关系进行约束是非常重要的. 双向关联意味着只有将这两个对象放在一起考虑才能理解他们. 但应用程序不要求双向遍历时, 可以指定一个遍历方向, 以便减少相互依赖, 并简化设计. 这种简化表明一个方向的关联比另一个方向的关联更有意义且更重要.

通过更深入的理解可得到一个限定的关系. 限定的条件能把多重关系简化为一对一关系. 并在模型中植入了一条明确的规则.

限定多对多关联的遍历方向可以有效地将其实现简化为一对多关联, 从而得到一个简单得多的设计.

坚持将关联限定为领域所倾向的方向, 不仅可提高这些关联的表达力并简化其实现, 而且还可以突出剩下的双向关联的重要性. 当双向关联是领域的一个语义特征时, 或者当应用程序的功能要求双向关联时, 就需要保留它, 以表达出这些需求.

模式: Entity (又称为Reference Object)

很多对象不是通过它们的属性定义的, 而是通过连续性和标识定义的.

对象建模可能把我们的注意力引到对象的属性上, 但实体的基本概念上一种贯穿整个生命周期的抽象的连续性.

主要由标识定义的对象被称作Entity, Entity具有生命周期, 这期间它们的形式和内容可能发生改变, 但必须保持一种内在的连续性, 为了有效地跟踪这些对象, 必须定义它们的标识. 它们的类定义, 职责, 属性和关联必须由其标识来决定, 而不依赖其他属性.

Entity可以是任何事物, 只需要满足两个条件即可:

  1. 它在整个生命周期中具有连续性.
  2. 它的区别并不是由那些对用户非常重要的属性决定的.

标识是Entity一个微妙的, 有意义的属性, 不能交给语言的自动特性来处理的.

当一个对象是由其标识而不是属性区分时, 那么在模型中应该主要通过标识来确定该对象的定义. 使类定义变得简单, 并集中关注生命周期的连续性和标识. 需要定义一种区分每个对象的方式, 这种方式应该与其形式和历史无关. 要格外注意需要通过属性来匹配对象的需求.

定义标识操作时, 要确保这种操作为每个对象生成唯一的结果, 可通过附加一个保证唯一性的符号来实现. 模型必须定义出符合什么条件才算是相同的事物.

在现实世界中, 并不是每一个事物都必须有一个标识, 标识重不重要, 完全取决于它是否有用. 现实世界的同一个事物在领域模型中可能需要表示为Entity, 也可能不需要表示为Entity.

Entity建模

Entity最基本的职责是确保连续性, 以便其行为更清楚且可预测. 保持实体的简练是实现这一责任的关键. 不要将注意力集中在属性或行为上, 应抓住Entity对象定义的最基本特征, 尤其是用于识别, 查找或匹配对象的特征, 只添加那些对概念至关重要的行为和这些行为所必须的属性.

此外, 其他的行为和属性应转移到与核心实体关联的其他对象中. 实体往往通过协调其关联对象的操作来完成自己的职责.

设计标识操作

每个Entity都必须有一种建立标识的操作方式, 以便与其他对象区分开, 即使这些对象与它具有相同的描述属性. 不管系统是如何定义的, 都必须确保标识属性在系统中是唯一的, 即使在分布式系统中, 或者对象已被归档(数据库), 也必须确保标识的唯一性.

标识是在模型中定义的, 定义标识要求理解领域.

有时, 某些数据属性或属性组合可以确保它们在系统中具有唯一性, 或在这些属性上加一些简单约束可使其具有唯一性, 这种方法为Entity提供了唯一键.

当对象属性没办法形成真正的唯一键时, 另一种常用到的解决方案是为每个实例对象附加一个在类中唯一的符号. 一旦这个ID符号被创建并存储为Entity的一个属性, 必须将它指定为不可变的.

ID通常时由系统自动生成的, 生成算法必须确保ID在系统中是唯一的.

当自动生成ID时, 用户可能不需要看到它, 只是在系统内部被需要. 有些情况下用户会对生成的ID感兴趣. 有些情况下, 要确保ID在多个计算机系统中具有唯一性. 有些情况下, 可以使用电话号码作为标识符, 但号码可能是共用的, 也可能会更改, 此时需使用特别指定的标识符并使用其他属性进行匹配和验证.

在任何情况下, 当应用程序需要一个外部ID时, 都由系统和用户负责提供唯一的ID, 而系统必须为用户提供适当的工具来处理异常的情况.

模式: Value Object

很多对象没有概念上的标识, 只描述了一个事物的某种特征.

跟踪Entity的标识上非常重要的, 但为其他对象也加上标识会影响系统性能并增加分析工作, 而且会使模型变得混乱, 因为所有的对象看起来都是相同的.

软件设计要时刻与复杂性做斗争, 必须区别对待问题, 仅在真正需要的地方进行特殊处理. 如果仅仅把这类对象当作没有标识的对象, 那么就忽略了它们的工具价值或术语价值. 这些对象有自己的特征, 对模型也有重要的意义. 这些对象是用来描述事物的.

用于描述领域的某个方面而本身没有概念标识的对象称为Value Object值对象. 我们只关心它们是什么, 不关心它们是谁.

Value Object值对象可以是其他对象的集合, 甚至可以引用Entity, 经常作为参数在对象之间传递消息.

当只关心一个模型元素的属性时, 应把它归类为Value Object值对象. 应该使这个模型元素能够表示出其属性的意义, 并为它提供相关功能. Value Object应该是不可变的, 不要为它分配任何标识, 而且不要把它设计成Entity那么复杂.

Value Object值对象所包含的属性应该形成一个概念整体.

设计Value Object

设计Value Object时有多种选择, 包括复制, 共享或保持Value Object不变.

为了安全的共享一个Value Object对象, 必须确保其是不可变的, 否则将其整个替换掉. Value Object的改变可能会破坏所有者的约束条件, 可通过传递一个不变对象或传递一个副本来解决.

Value Object为性能优化提供了更多选择, 因为Value Object往往数量很多.

复制和共享哪个更划算取决于实现环境. 虽然复制可能导致系统被大量的对象阻塞, 但共享可能会减慢分布式系统的速度.

以下几种情况最好使用共享:

  1. 节省数据库空间或减少对象数量是一个关键要求时
  2. 通信开销很低时
  3. 共享的对象被严格限定为不可变时

只要Value Object是不可变的, 变更管理很简单, 出了整体替换之外没有其他的更改. 不变的对象可以自由地共享. 在设计中将一个Value Object指定为不可变时, 可以完全根据技术需求来决定使用复制还是共享, 应用程序不依赖于对象的特殊实例.

Value Object允许可变的情况:

  1. Value Object频繁改变
  2. 创建和删除对象的开销很大
  3. 替换时将打乱集群
  4. Value Object共享的不多, 或者共享不会提高集群性能, 或其他技术原因.

再次强调: 如果一个Value Object的实现是可变的, 那么就不能被共享. 无论是否共享Value Object, 在可能的情况下都要将它们设计为不可变的.

在关系数据库中, 可以将一个具体的值放到有此值的Entity表中, 而不是关联到另一个单独的表. 在分布式系统中, 对一个位于另一台服务器上的Value Object的引用可能导致对消息的响应十分缓慢, 此时应该将整个对象的副本传递到另一台服务器上.

设计包含Value Object的关联

前面的Entity关联设计也适用于Value Object. 模型中的关联越少越好, 越简单越好.

两个Value Object之间的双向关联完全没有意义, 因为没有标识, 应该尽量完全清除Value Object之间的双向关联.

如果模型中看起来需要这种关联, 那么首先应重新考虑将对象声明为Value Object是否正确.

模式: Service

设计会包含一些特殊的操作, 这些操作从概念上讲不属于任何对象, 需在模型中引入新的元素即Service服务.

有些操作在本质上讲是一些活动或动作, 不是事物, 由于建模范式是对象, 因此要将它们划归到对象这个范畴里.

比较常见的错误是没有努力为这类行为找到一个适当的对象, 而逐渐转为过程化的编程. 将一个操作放到不合符对象定义的对象中时, 这个对象会产生概念上的混淆且很难理解或重构. 复杂的操作容易把一个简单对象搞乱, 使对象的角色变得模糊. 这些操作常常牵扯到很多领域对象, 需要协调这些对象以便它们工作, 而这会产生对所有这些对象的依赖, 将那些本来可以单独理解的概念缠杂在一起.

Service服务是作为接口提供的一种操作, 在模型中是独立的, 不具有像Entity和Value Object的封装状态.

Service服务强调的是与其他对象的关系, 定义了能够做什么, 往往以一个活动来命名, 它是动词而不是名词. Service有定义的职责, 这种职责和履行它的接口应该作为领域模型的一部分来加以定义. 操作名称应来自于Ubiquitous Language, 如果没有, 则应该将其引入. 操作的参数和结果应该是领域对象.

好的Service有下面三个特征:

  1. 与领域概念相关的操作不是Entity或Value Object的一个自然组成部分.
  2. 接口是根据领域模型的其他元素定义的.
  3. 操作是无状态的.

当领域中的某个重要过程或转换操作不是Entity或Value Object的自然职责时, 应该在模型中添加一个作为独立接口的操作, 并将其声明为Service服务. 定义接口时要使用模型语言, 并确保操作名称时Ubiquitous Language中的术语. 应该使Service成为无状态的.

Service与孤立的领域层

Service并不只是在领域层中使用, 需要注意区分属于领域层的Service和那些属于其他层的Service, 并划分责任, 以便将它们明确地分开.

纯技术的Service属于基础设施层且没有任何业务意义, 领域层和应用层的Service与基础设施层的Service进行协作.

应用层Service和领域层Service可能很难区分. 应用层负责通知的设置, 而领域层负责确定是否满足临界值, 尽管这项任务可能并不需要使用Service, 因为任务可作为Entity或Value Object的职责.

很多领域或应用层的Service是在Entity和Value Object的基础上建立起来的, 其行为类似于将领域的一些潜在功能组织起来以执行某种任务的脚本. Entity和Value Object往往由于粒度过细而无法提供对领域层功能的便捷范围.

如资金转账在银行领域语言中时一项有意义的操作, 而且它涉及基本的业务逻辑, 如果设计一个Service来处理资金转账功能, 那么这个Service将属于领域层.

在大多数开发系统中, 在一个领域对象和外部资源资金直接建立一个接口上很别扭的, 可以利用一个Facade外观将外部的Service包装起来, 这个外观可能以模型作为输入, 并返回一个对象作为它的结果.

粒度

Service服务可以控制领域层中接口的粒度, 并且避免客户端与Entity和Value Object耦合.

在大型系统中, 中等粒度的, 无状态的Service更容易被复用, 因为其在简单的接口背后封装了重要的功能. 细粒度的对象可能导致分布式系统的消息传递的效率低下.

由于应用层负责对领域对象的行为进行协调, 因此细粒度的对象可能会把领域层的知识泄漏到应用层中, 后果是应用层不得不处理复杂的细致的交互, 从而使得领域层知识蔓延到应用层或用户界面代码中, 而领域层会丢失这些知识. 明智地引入领域层Service有助于在应用层和领域层之间保持一条明确的界限.

这种模式有利于保持接口的简单性, 便于客户端控制并提供多样化的功能, 提供了一种在大型或分布式系统中便于对组件进行打包的中等粒度的功能.

对Service的访问

与分离特定的职责的设计决策相比, 提供对Service的访问机制意义并不是十分重大, 一个操作对象足以作为Service接口的实现.

模式: Module (也称为Package)

Module是一个传统的, 较成熟的设计元素. Module为人们提供了两种观察模型的方式: 一是可以在Module中查看细节, 而不会被整个模型淹没; 二是观察Module之间的关系, 而不考虑其内部细节.

领域中的Module应该成为模型中有意义的部分, Module从更大的角度描述了领域.

Module之间应该是低耦合的, 在Module内部则是高内聚的. Module不仅仅是代码的划分, 而且是概念的划分.

Module之间的低耦合可以帮助理解不同Module中的模型元素在设计中的作用, 且在分析一个Module内容时, 只需很少地参考那些与之交互的其他Module.

仔细选择的Module可以将那些具有紧密概念关系或相关职责的模型元素集中到一起, 这会极大地降低建模和设计的复杂性.

重构Module需要比重构类做更多的工作, 也具有更大的破坏性, 但可能不会特别频繁. 让Module反映出对领域理解的不断变化, 可使Module中的对象能够更自由地演变.

Module是一种表达机制, 它的选择应该取决于被划分到模块中的对象的意义. Module的名称表达了意义, 应该被添加到Ubiquitous Language术语中, 其名称应反映出领域的深层知识. 只有以模型为中心进行思考, 才能得到更深层的解决方案.

敏捷的Module

Module需要与模型的其他部分一同演变, Module的重构必须与模型和代码一起进行. 更改Module可能需要大范围地更新代码.

Module选择的早期, 有些错误不可避免, 这些错误导致了高耦合, 从而使Module很难进行重构, 而缺乏重构会导致问题变得更加严重. 克服这一问题的唯一方法是接受挑战, 仔细地分析问题的要害所在, 并据此重新组织Module.

通过基础设施打包时存在的隐患

技术框架对打包决策有着极大的影响, 有些技术框架是有帮助的, 有些则要坚决抵制.

常用的Layered Architecture分层架构, 将基础设施和用户界面代码放到两组不同的包中, 并且从物理上把领域层隔离到它自己的一组包中. 但分层架构可能导致模型对象实现的分裂. 有些框架的分层方法是把一个领域对象的职责分散到多个对象当中, 然后把这些对象放到不同的包中.

这种框架设计尝试解决关注点的逻辑划分问题: 一个对象负责数据库访问, 另一个对象负责处理业务逻辑. 虽然这种划分方法使人们在技术层面上更容易理解每个层的功能, 而且更容易切换各个层, 但这种设计的问题在于没有顾及应用程序的开发成本.

这种打包方案的另一个动机是层的分布. 如果代码实际上被部署到不同的服务器上, 那么这会成为这种分层的有力论据, 但通常实际上并不是这样, 应该在需要时才寻求灵活性. 在一个希望充分利用Model-Driven Design的项目上, 分层设计的牺牲太大, 除非为了解决一个紧迫的问题.

精巧的技术打包方案会产生下面两个代价:

  1. 如果框架的分层惯例把实现概念对象的元素分得很零散, 那么代码将无法再清楚地表示模型.
  2. 人的大脑把划分后的东西还原成原样的能力是有限的, 如果框架把人的这种能力都耗尽了, 那么领域开发人员无法再把模型还原成有意义的部分了.

如果需要极度简化技术分层规则, 要么这些规则对技术环境特别重要, 要么这些规则真正有助于开发.

除非真正有必要将代码分布到不同的服务器上, 否则就把实现单一概念对象的所有代码放在同一模块中(如果不能放在同一对象中的话).

利用打包把领域层从其他代码中分离出来. 否则尽可能让领域开发人员自由地决定领域对象的打包方式, 以便支持他们的模型和设计选择.

领域模型中的每个概念都应该在实现元素中反映出来; 实现中的对象, 指针和检索机制必须直接清楚地映射到模型元素. 如果没有做到, 就要重写代码或修改模型.

不要在领域对象中添加任何与领域对象所表示的概念没有紧密关系的元素. 领域对象的职责是表示模型.

建模范式

Model-Driven Design要求使用一种与建模范式协调的实现技术. 主流的范式是面向对象设计, 而且现在的大部分复杂项目都开始使用对象.

对象范式流行的原因

对象建模在简单性和复杂性之间实现了一个很好的平衡. 大部分人比较容易理解面向对象设计的基本知识, 且对象建模的丰富功能足以捕获重要的领域知识.

对象范式已经发展很成熟并得到广泛采用, 有成熟的基础设施和工具支持, 其开发者社区和设计文化已经成熟, 相关书籍也很多.

但并不意味着应该永远只局限于对象技术, 对象技术虽然可以解决很多实际的软件问题, 但一些领域(如涉及大量数学问题的领域或受全局逻辑推理控制的领域)就不适合使用面向对象的范式.

对象世界中的非对象

领域模型不一定是对象模型. 模型范式为人们提供了思考领域的方式, 这些领域的模型由范式塑造成型.

不管在项目中使用哪种主要的模型范式, 领域中都会有一些部分更容易用某种其他范式来表达.

当领域中只有个别元素适合用其他范式时, 开发人员可以接受一些蹩脚的对象, 以使整个模型保持一致.

当领域的主要部分明显属于不同的范式时, 明智的做法是用适合各个部分的范式对其建模, 并使用混合工具集来进行实现.

当领域的各个部分之间的相互依赖性较小时, 可以把用另一种范式建立的子系统封装起来, 如业务规则引擎或工作流引擎这样的非对象组件集成到对象系统.

混合使用不同的范式使得开发人员能够用最适当的风格对特殊概念进行建模.

在混合范式中坚持使用Model-Driven Design

在面向对象的应用程序开发项目中, 有时会混合使用一些其他的技术, 如规则引擎.

但有时会缺少一种能够显示出衔接两种实现环境的概念模型相关性的无缝视图, 造成应用程序被割裂成两部分. 重要的是在使用规则引擎的同时要继续考虑模型, 必须找到能够同时适用于两种实现范式的单一模型.

如果没有无缝的环境, 就要完全靠开发人员提炼出一个由清晰的基本概念组成的模型, 以便完全支撑整个设计.

将各个部分紧密结合在一起的最有效工具是健壮的Ubiquitous Language, 它是构成整个异构模型的基础, 有助于消除两种环境之间的鸿沟.

Model-Driven Design不一定是面向对象, 但确实需要一种富有表达力的模型结构实现, 如果可用工具无法提高表达力, 就要重新考虑选择工具. 缺乏表达力的实现将削弱各种范式的优势.

将非对象元素混合到以面向对象为主的系统时, 需要遵循以下四条经验规则:

  1. 不要和实现范式对抗, 总是可以用别的方式来考虑领域, 找到适合于范式的模型概念.
  2. 把Ubiquitous Language通用语言作为依靠的基础. 即使工具间没有严格的联系, 语言使用上的高度一致性也能防止各个设计部分分裂.
  3. 不要一味依赖UML. UML不是在所有情况下都适合表达约束, 有时使用其他风格的图形或者简单的语言描述比牵强附会地适应某种对象视图更好.
  4. 保持怀疑态度. 不能因为存在一些规则, 就必须适应规则引擎. 规则也可以表示为对象, 虽然可能不太优雅.

在决定适应混合范式之前, 一定要确信主要范式中的各种可能性都已经尝试过了.

领域对象的生命周期

每个对象都有生命周期. 有些对象具有更长的生命周期, 与其他对象具有复杂的相互依赖性, 它们会经历一些状态变化, 在变化时要遵守一些固定规则.

管理这些对象的挑战有:

  1. 在整个生命周期中维护完整性.
  2. 防止模型陷入管理生命周期复杂性造成的困境中.

通过三种模式解决这些问题:

  1. Aggregate聚合: 通过定义清晰的所属关系和边界, 并避免混乱, 错综复杂的对象关系网来实现模型的内聚. Aggregate聚合模式对维护生命周期的各个阶段的完整性有至关重要的作用.
  2. Factory工厂: 在生命周期的开始阶段, 创建和重建复杂对象和Aggregate聚合, 从而封装它们的内部结构.
  3. Repository存储库: 在生命周期的中间和末位阶段, 提供查找和检索持久化对象并封装庞大基础设施的手段.

使用Aggregate聚合进行建模, 并在设计中结合使用Factory工厂和Repository存储库, 这样能够在模型对象的整个生命周期中以有意义的单元或系统性地操纵它们.

Aggregate聚合可以划分范围, 范围内的模型元素在生命周期的各个阶段都应该维护其固定规则. Factory工厂和Repository存储库在Aggregate聚合基础上操作, 将特定生命周期转换的复杂性封装起来.

模式: Aggregate

在具有复杂关联的模型中, 要想保证对象更改的一致性时很困难的. 不仅互不关联的对象需要遵守一些固定规则, 而且紧密关联的各组对象也要遵守一些固定规则. 然而过于谨慎的锁定机制会导致多个用户之间毫无意义地相互干扰, 从而使系统不可用.

尽管从表面上看这个问题上数据库事务方面的一个技术难题, 但其根源在模型, 归根结底是由于模型中缺乏明确定义的边界. 从模型得到的解决方案将使模型更易于理解, 并且使设计更易于沟通.

应该将Entity和Value Object分门别类地聚集到Aggregate中, 并定义每个Aggregate的边界. 在每个Aggregate中选择一个Entity作为根, 并通过根来控制对边界内其他对象的所有范围. 只允许外部对象保持对根的引用. 对内部成员的临时引用可以被传递出去, 但仅在一次操作中有效. 由于根控制访问, 因此不能绕过它来修改内部对象. 这种设计有利于确保Aggregate中的对象满足所有固定规则, 也可以确保在任何状态变化时Aggregate作为一个整体满足固定规则.

Aggregate是一组相关对象的集合, 并把它作为数据修改的单元. 每个Aggregate都有一个root根和一个boundary边界. boundary边界定义了Aggregate的内部有什么, root根则是Aggregate包含的一个特定Entity. 对Aggregate而言, 外部对象只可以引用根, 而boundary内部的对象之间可以互相引用.

固定规则invariant是指数据变化时必须保持的一致性规则, 涉及Aggregate成员之间的内部关系. 每个事务完成时, Aggregate内部所应用的固定规则必须得到满足.

为实现Aggregate, 需要对所有事务应用一组规则:

  1. 根Entity具有全局表示, 最终负责检查固定规则.
  2. 边界内的Entity具有本地标识, 标识只在Aggregate内部是唯一的.
  3. Aggregate外部的对象不能引用除根Entity之外的任何内部对象. 根Entity可以把内部Entity的引用传递给外部对象, 但外部对象只能临时使用, 而不能保持引用. 根Entity可以把一个Value Object的副本传递给外部对象.
  4. 只有Aggregate的根才能直接通过数据库查询获取, 所有其他对象必须通过遍历关联来发现.
  5. Aggregate内部的对象可以保持对其他Aggregate根的引用.
  6. 删除操作必须一次三次Aggregate边界之内的所有对象.
  7. 当提交对Aggregate边界内部任何对象的修改时, 整个Aggregate的所有固定规则必须被满足.

模式: Factory

如果创建一个对象或整个Aggregate很复杂或暴露了过多的内部结构, 则可以使用Factory进行封装.

对象的功能主要体现在其复杂的内部配置和关联关系. 一个对象在其生命周期中要承担大量职责, 如果再让复杂对象负责自身的创建, 那么职责将会过载.

对象的创建本身可以是一个主要操作, 但被创建的对象并不适合承担复杂的装配操作. 将这些职责混在一起可能产生难以理解的拙劣设计. 让客户端直接负责创建对象又会使客户的设计陷入混乱, 并且破坏被装配对象或Aggregate的封装, 而且导致客户端与被创建对象的实现之间产生过于紧密的耦合.

应该将创建复杂对象的实例和Aggregate的职责转移给单独的对象, 这个对象本身可能没有承担领域模型中的职责, 但它仍是领域设计的一部分. 提供一个封装所有复杂装配操作的接口, 而且这个接口不需要客户引用要被实例化的对象的具体类. 在创建Aggregate时要把它作为一个整体, 并确保满足固定规则.

Factory工厂有很多种设计模式, 如Factory Method工厂方法, Abstract Factory抽象工厂和Builder构建者. 好的工厂都需要满足以下两个基本要求:

  1. 每个创建方法都是原子的, 而且要保证被创建对象或Aggregate的所有固定规则. Factory生成的对象要处于一致的状态.
  2. Factory应该被抽象为所需的类型, 而不是所要创建的具体类.
选择Factory及其应用位置

Aggregate聚合与把Factory工厂用在哪些需要隐藏细节的地方的决定有关.

Factory工厂与被构建对象之间是紧密聚合的, 因此Factory工厂应该只被关联到与被构建对象有着密切联系的对象上.

当有些细节需要隐藏而又找不到合适的地方来隐藏时, 必须创建一个Factory对象或Service.

整个Aggregate通常由一个独立的Factory来创建, Factory负责把对根的引用传递出去, 并确保创建出的Aggregate满足固定规则.

如果Aggregate内部的某个对象需要一个Factory, 而这个Factory又不适合在Aggregate根上创建, 那么应该构建一个独立的Factory, 但仍应遵守规则即把访问限制在Aggregate内部, 并确保Aggregate外部只能对被构建对象进行临时引用.

有些情况下只需使用构造函数

有些情况下直接使用构造函数是最佳选择, Factory工厂会使不具有多态性的简单对象复杂化. 以下情况最好使用简单的, 公共的构造函数:

  1. 类class是一种类型type, 不是任何相关层次结构的一部分, 而且没有通过接口实现多态性.
  2. 客户端关心的是实现, 可能将其作为选择Strategy的一种方式.
  3. 客户端可以访问对象的所有属性, 因此向客户端公开的构造函数中没有嵌套的对象创建.
  4. 构造并不复杂.

公共构造函数必须遵守与Factory相同的规则: 其必须是原子操作, 且满足被创建对象的所有固定规则.

不要在构造函数中调用其他类的构造函数, 构造函数应该保持绝对简单.

Java中的所有集合都实现了接口, 集合类可以使用Factory来封装集合的层次结构的, 客户端也可以使用Factory方法来请求所需的特性, 然后由Factory来选择适当的类来实例化. 这样创建集合的代码会有更强的表达力, 且新增集合类不会破坏现有的Java程序.

在很多应用程序中, 实现方式的选择对性能的影响是非常敏感的, 因此应用程序需要控制选择哪种实现, 此时使用具体的构造函数更为合适. 尽管如此, 真正智能的Factory仍然可以满足这些因素的要求.

虽然没有使用Factory, 但抽象集合类型仍然具有一定价值, 原因在于它们的使用模式. 集合通常在一个地方创建, 在其他地方使用. 这意味着最终使用集合的客户端仍可以与接口进行对话, 从而不与实现发生耦合.

接口的设计

当设计Factory的方法签名时, 需要记住:

  1. 每个操作都必须时原子的. 必须在与Factory的一次交互中把创建对象所需的所有信息传递给Factory. 同时必须确定当创建失败时将执行什么操作.
  2. Factory将与其参数发生耦合. 如果在选择输入参数时不小心, 可能会产生错综复杂的依赖关系, 耦合程度取决于对参数argument的处理.

最安全的参数来自较低设计层的参数. 一个好的参数选择时模型中与被构建对象密切相关的对象, 这样不会增加新的依赖. 使用抽象类型的参数, 而不是它们的具体类.

固定规则的相关逻辑放置位置

Factory工厂负责确保它所创建的对象或Aggregate满足所有固定规则, 可以将固定规则的检查工作委派给被创建对象, 通常是最佳选择.

对于Aggregate来说, 把固定规则的相关逻辑放到Factory工厂中, 这样可以让被创建对象的职责更明晰. 但固定规则的相关逻辑却特别不适合放到那些与其他领域对象关联的Factory Method中.

虽然原则上在每个操作结束时都应该应用固定规则, 但通常对象所允许的转换可能永远不会用到这些规则. Entity标识属性的赋值需要满足一条固定规则, 但在标识创建后可能一直保持不变. Value Object则是完全不变的.

如果逻辑在对象的有效生命周期内永远不被用到, 那么对象没有必要携带这个逻辑. 这种情况下, Factory工厂是放置固定规则的合适地方, 这样可以使Factory创建出的对象更简单.

Entity Factory和Value Object Factory

由于Value Object是不可变的, 因此Factory生成的对象就是最终形式, Factory必须得到被创建对象的完整描述.

Entity Factory只需要具有构造有效Aggregate所需的那些属性, 对于固定规则不相关的细节, 可以之后再添加.

Entity分配标识符时, 如果是通过外部提供的标识, 那么标识必须作为参数被显式地传递给Factory. 如果是由程序分配标识符, Factory是控制它的理想场所. 尽管标识符ID是由数据库序列或其他基础设施机制生成的, 但Factory知道需要什么样的标识符以及将标识符放到何处.

重建已存储的对象

大部分对象要存储在数据库中或通过网络传输, 几乎没有哪种技术能够保持对象的内容特征, 大多数传输方法都要将对象转换为平面数据才能传输, 这使得对象只能以非常有限的形式出现. 因此检索操作潜在地需要一个复杂的过程将各个部分重新装配成一个可用的对象.

用于重建对象的Factory和用于创建对象的Factory很类似, 但有以下两点不同:

  1. 用于重建对象的Entity Factory不分配新的跟踪ID, 标识属性必须是输入参数的一部分.
  2. 当固定规则未被满足时, 重建对象的Factoty采用不同的方式处理, 如从数据库重建对象时, 对象映射技术可以提供部分或全部所需服务.

Factory封装了对象创建和重建时的生命周期转换. Repository则封装了对象与存储之间的转换.

模式: Repository

可以通过对象之间的关联来找到对象, 此时必须要有一个起点. 无论用对象执行什么操作, 都需要保持一个对它的引用. 获得这个引用的方法: 一是创建对象; 二是遍历关联, 以一个已知对象作为起点, 并向它请求一个关联的对象.

由于对象存储在关系数据库中, 于是有第三种获得引用的方式: 基于对象的属性, 执行查询来找到对象, 或找到对象的组成部分, 然后重建它.

从技术的观点来看, 检索已存储对象实际上属于创建对象的范畴; 但从概念上讲, 对象检索发生在Entity生命周期中, 使用已存储的数据创建实例的过程称为重建.

开发人员如果使用SQL查询直接获得所需的数据, 会导致越来越多的领域规则被嵌入到查询代码中.

客户端需要一种有效的方式来获取已存在的领域对象的引用. 如果基础设施提供了这方面的便利, 那么开发人员可能会增加很多可遍历的关联, 这会使模型非常混乱.

另一方面, 开发人员可能使用查询从数据库中提取所需的数据, 或是直接提取具体的对象, 而不是通过Aggregate的根来得到这些对象, 这样导致领域逻辑进入查询和客户端代码中, 而Entity和Value Object则变成单纯的数据容器. 采用数据库访问的技术复杂性会使客户端代码变得混乱, 将导致开发人员简化领域层, 最终使模型变得无关紧要.

在持久化对象中, 当很难通过遍历方式来访问某些Aggregate根的时候, 需要使用通过基于对象属性搜索的访问方式. 这些对象通常是Entity, 有时是有复杂内部结构的Value Object, 还可能是枚举Value, 而其他对象不宜采用这种访问方式, 这会混淆它们之间的重要区别. 随意的数据库查询会破坏领域对象的封装和Aggregate, 技术基础设施和数据库访问机制的暴露会增加客户端的复杂度, 并妨碍模型驱动的设计.

为每种需要全局访问的对象类型创建一个Repository对象, 这个对象相当于该类型的所有对象在内存中的一个集合替身. 通过一个全局接口提供访问, 提供添加和删除对象的方法, 用于封装在数据存储中实际插入或删除数据的操作. 提供根据具体条件来挑选对象的方法, 并返回属性值满足查询条件的对象或对象集合, 从而将实际的存储和查询技术封装起来.

只为那些确实需要直接访问的Aggregate根提供Repository. 让客户端始终聚焦于模型, 将所有对象的存储和访问操作交给Repository来完成.

Repository的优点:

  1. 为客户端提供了一个简单模型, 可用来获取持久化对象并管理它们的生命周期.
  2. 使应用程序和领域设计与持久化技术解耦.
  3. 体现了有关对象访问的设计决策.
  4. 很容易将它们替换为哑实现dummy implementation, 以便在测试中使用.
Repository的查询

Repository为客户端提供了根据某种条件来查询对象的方法, 设计这个接口有很多选择.

最容易构建的Repository用硬编码的方式实现具有特定参数的查询.

在需要执行大量查询的项目上, 可以构建一个支持更灵活查询的Repository框架.

基于Specification规格的查询是将Repository通用化的好方法, 优雅且灵活. 客户端可以使用Specification规则来描述它需要什么, 而不必关心如何获得结果.

客户代码可以忽略Repository的实现, 但开发人员不能忽略

持久化技术的封装可以使得客户端变得十分简单, 并且使客户端与Repository的实现之间完全解耦, 但开发人员必须知道在封装背后都发生了什么事情. 在使用Repository时, 不同的使用方式或工作方式可能会对性能产生极大的影响.

Repository的实现

理想的Repository的实现是向客户端隐藏所有内部工作细节. 将存储, 检索和查询机制封装起来是Repository实现的最基本特征.

实现时需要谨记的注意事项:

  1. 对类型进行抽象. 类型可以是抽象超类, 可以是接口, 可以是具体类.
  2. 充分利用与客户端解耦的优点, 可以很容易地更改Repository的实现, 也可以利用解耦来优化性能.
  3. 将事务的控制权留给客户端. 只有客户端才有上下文, 能够正确地初始化和提交工作单元.
在框架内工作

在实现Repository的构造之前, 需要认真思考所使用的基础设施, 特别是架构框架. 架构框架可能已经定义了一种用来获取持久化对象的等效模式, 也有可能定义了一种与Repository完全不同的模式.

使用框架时要顺其自然. 当框架无法切合时, 要在大方向上保持领域驱动设计的基本原理, 一些不符的细节不必苛求, 寻求领域驱动设计的概念与框架中的概念之间的相似性.

Repository与Factory的关系

Factory负责处理对象生命周期的开始, 而Repository帮助管理生命周期的中间和结束.

从领域驱动设计的角度来看, Repository和Factory具有完全不同的职责. Factory负责制造新对象, 而Repository负责查找已有对象. 职责的明确区分有助于Factory摆脱所有持久化职责.

最好不要追求把Factory和Repository结合起来实现查找不到就新创建的功能. 通常在领域中将新对象和原有对象区分开是很重要的, 而将它们组合在一起的框架实际上只会使局面变得混乱.

为关系数据库设计对象

以面向对象技术为主的软件系统中, 最常用的非对象组件是关系数据库. 数据库与对象模型的关系与其他组件相比更要紧密. 数据库不仅仅与对象交互, 而且还把构成对象的数据存储为持久化形式.

如果数据库模式是专门为对象存储而设计的, 那么接受模型的一些限制是值得的, 这样使映射变得简单. 从技术上看, 关系表的设计不必反映出领域模型. 因为领域驱动设计的避免将分析和设计模型分开的观点, 有时必须在数据库设计中做出一些折中, 重要的是映射要保持透明, 并易于理解(能够通过审查代码或阅读映射工具的条目就搞明白).

当数据库被视作对象存储时, 数据模型与对象模型的差别不应太大, 可以牺牲一些对象关系的丰富性, 以保证它与关系模型的紧密关联. 如果有助于简化对象映射的话, 可以牺牲某些正式的关系范式.

对象系统外部的过程不应该访问这样的对象存储, 可能会破坏对象必须满足的固定规则, 此外它们的访问会锁定数据模型, 使得在重构对象时很难修改模型.

数据如果是来自遗留系统或外部系统的, 而这些系统从来没打算被用作对象的存储, 这种情况下会有两个领域模型共存, 其他章节将讨论这个问题. 为了性能, 有时可能需要对设计做出一些非常规的修改.

大多数情况下关系数据库是面向对象领域中的持久化存储形式, 简单的对应关系才是最好的.

Ubiquitous Language可能有助于将对象和关系组件联系起来, 使之成为单一的模型. 对象中的元素的名称和关系应该严格地对应于关系表中相应的项.

对象模型的重构对关系数据库的设计并没有太大的影响. 即使有严重的数据迁移问题也不愿意对数据库进行频繁的修改. 这可能会阻碍对象模型的重构, 但如果对象模型和数据库模型开始背离, 那么很快就会失去透明性.

当对象的行为快速变化或演变的时候, 数据库可能并不需要修改, 让模型与数据库之间保持松散的关联是很有吸引力的, 可能会产生更整洁的数据库模型.

通过重构来加深理解

在开发过程中, 真正的挑战是找到深层次的模型, 这个模型不仅能够捕捉到领域专家的微妙关注点, 还可驱动切实可行的设计.

要想成功地开发出实用的模型, 需要注意下面三点:

  1. 复杂巧妙的领域模型是可以实现的, 也值得去花费力气实现.
  2. 模型离开不断的重构很难开发出来, 重构需要领域专家和热爱学习领域知识的开发人员密切参与进来.
  3. 要实现并有效地运用模型, 需要精通设计技巧.

重构的层次

重构是在不改变软件功能的前提下重新设计它. 开发人员无需在着手开发之前做出详细的设计决策, 只需要在开发过程中不断小幅调整设计即可, 这样不但能够保证软件原有的功能不变, 还可使整个设计更加灵活易懂.

重构除了设计模式重构代码细节重构, 还有为实现深层次模型而进行的重构. 在深入理解领域的基础上进行重构, 通常需要实现一系列的代码细节重构, 甚至是设计模式重构, 通过这些重构可以得到更深层次的模型.

领域模型的重构会随着新知识的出现而不断变化. 领域建模本质上是非结构化的, 需要学习, 深入思考, 发挥创造力, 才能得到深层次的模型.

深层模型

深层模型能够穿过领域表象, 清楚地表达出领域专家们的主要关注点以及最相关的知识. 恰当反映领域的深层模型通常具有功能多样, 简单易用, 解释力强的特性.

柔性设计

柔性设计supple design使得设计更易于修改和使用, 有助于改进模型本身, 使得设计能清晰地表达出领域含义.

每次对模型和代码所进行的修改都能反映出对领域的新理解, 通过不断的重构能给系统最需要修改的地方增添灵活性, 并找到简单快捷的方式来实现普通的功能, 同时反复的修改能让我们越来越接近柔性设计.

发现过程

要想创建出确实能够解决当前问题的设计, 首先必须拥有可捕捉到领域核心概念的模型.

由于模型和设计之间具有紧密的关系, 如果代码难于重构, 建模过程也会停滞不前.

需要富有创造力, 不断地尝试, 不断地发现问题才能找到合适的方法为所发现的领域模型建模, 有时可以借用别人已建好的模式.

突破

重构的投入与回报并非线性关系. 通常小的调整会带来小的回报, 小的改进也会积少成多. 小改进可防止系统退化, 成为避免模型变得陈腐的第一道防线. 有时有些最重要的理解会突然出现, 给整个项目带来巨大的冲击. 一系列微小的重构会逐渐汇聚成深层模型.

持续重构让事物逐步变得有序, 代码和模型的每一次精化都让开发人员有了更加清晰的认识, 使得理解上的突破成为可能. 这种突破不是某种技巧, 而是一个事件, 它的困难之处在于需要判断发生了什么, 然后在决定如何处理.

当突破带来更深层的模型时, 通常会令人感到不安. 与大部分重构相比, 这种变化的回报更多, 风险也更高, 而且突破出现的时候可能很不合时宜.

尽管希望项目进展顺利, 但往往事与愿违. 过渡到真正的深层模型需要从根本上调整思路, 并对设计做大幅修改. 在很多项目中, 建模和设计工作最重要的进展都来自于突破.

不要试图去制造突破, 那只会使项目陷入困境. 通常只有在实现了许多适度的重构后才有可能出现突破. 大部分时间里都在进行微小的改进, 在连续的改进中模型的深层含义也会逐渐显现.

为突破做好准备, 应专注于知识消化过程, 同时要逐渐建立健壮的Ubiquitous Language. 寻找重要的领域概念, 并在模型中清晰地表达出来. 精化模型使其更具柔性. 提炼模型. 利用这些容易掌握的手段使模型变得更清晰, 这通常会带来突破.

不要犹豫着不去做小的改进, 这些改进即使脱离不开常规的概念框架, 也可以逐渐加深对模型的理解.

将隐式概念转变为显式概念

深层模型之所以强大因为其包含了领域的核心概念和抽象, 能够以简单灵活的方式表达出基本的用户活动, 问题以及解决方案.

深层建模的第一步就是设法在模型中表达出领域的基本概念, 然后在不断的消耗知识和重构的过程中, 实现模型的精化.

若开发人员识别出设计中隐含的某个概念或是在讨论中受到启发而发现一个概念时, 就会对领域模型和相应的代码进行许多转换, 在模型中加入一个或多个对象或关系, 从而将此概念显式地表达出来.

有时从隐式概念到显式概念的转换可能是一次突破, 是我们得到一个深层模型. 更多时候突破不会马上带来, 而需要在模型中显示表达出许多重要概念, 并通过一系列重构不断地调整对象职责, 突破才会姗姗到来.

要实现上述过程, 必须首先识别出以某种形式存在的隐患概念, 无论这些概念多么原始.

概念挖掘

开发人员必须能够敏锐地捕捉到隐含概念的蛛丝马迹, 但有时必须主动寻找线索. 要挖掘大部分的隐患概念, 需要开发人员倾听团队语言, 仔细检查设计中的不足之处以及专家观点相矛盾的地方, 研究领域相关文献并进行大量的实验.

倾听语言

倾听领域专家的语言: 有没有术语能够简洁地表达出复杂的概念? 他们有没有纠正过你的用词? 当你使用某个特定词语时, 他们脸上是否已经不再流露出迷惑的表情? 这些都暗示了某个概念可以改进模型.

听到新单词只是个开头, 然后还要进行对话, 消化知识, 才能挖掘出清晰实用的概念. 如果出现了设计中没有的术语, 可以把它添加到Ubiquitous Language通用语言中.

检查不足之处

有时所需要的概念并不总是浮在表面上, 也绝不仅仅通过对话和文档就能让它显现出来. 有些概念需要自己去挖掘和创造. 要挖掘的地方就是设计中最不足的地方, 也是操作复杂且难于解释的地方, 每当有新需求变得更加复杂的地方.

有时很难意识到模型中丢失了什么概念, 也许对象能过实现所有的功能, 但有些职责的实现却很笨拙. 有时虽然能够意识到模型中丢失了东西, 却无法找到解决方案.

这个时候, 必须积极地让领域专家参与到讨论中来. 领域专家可能会愿意一起思考各种想法, 并通过模型来进行验证. 或者自己思索出不同的想法, 让领域专家对这些想法进行判断, 并注意观察专家的表情是认同还是反对.

思考矛盾之处

由于经验和需求的不同, 不同的领域专家对同样的事情会有不同的看法. 即使是同一个人提供的信息, 仔细分析后也发现逻辑上不一致的地方. 在挖掘需求时, 不断遇到这种矛盾, 但它们也为深层模型的实现提供了重要线索. 有些矛盾只是术语说法上的不一致, 有些则是由于误解而产生的. 但有一种情况时专家们会给出相互矛盾的两种说法.

可以通过假象实验的方式解决遇到的矛盾, 采用该方式可以帮助我们透过问题领域的表面获得更深层的理解.

解决所有的矛盾是不太现实的, 甚至是不需要的. 即使不去解决矛盾, 也应该仔细思考对立的两种看法是如何同时应用于同一个外部实现的, 会带来启示.

查阅书籍

在寻找模型概念时, 不要忽略一些显而易见的资源. 在很多领域中, 可以找到解释基本概念和传统思想的书籍, 但依然需要与领域专家合作, 提炼与你问题相关的那部分知识, 然后将其转化为适用于面向对象软件的概念. 查阅书籍也许能够使你一开始就形成一致且深层的认识.

看书和咨询领域专家并不冲突. 即使能够从领域专家得到充分的支持, 花点时间从文献资料中大致了解领域理论也是值得的. 大多数领域中都有一些善于思考的人, 他们已组织并抽象出了业务的一些通用惯例.

开发人员还可以阅读在此领域中有过开发经验的软件专业人员编写的资料, 虽然不能提供现成的解决方案, 但可以提供一些全新的实验起点, 以及在这个领域中探索过的人总结出来的经验, 这样可以避免开发人员重复设计已有的概念.

尝试, 再尝试

不断尝试不同的思路, 找到一个看起来足够清晰且实用的概念, 并在模型中尝试它. 随着经验的积累和知识的消化, 会有更好的想法. 建模人员和设计人员绝对不能固执己见.

并不是所有方向性的改变都毫无用处. 每次改变都会把开发人员更深刻的理解添加到模型中. 每次重构都使设计变得更灵活并且为那些可能需要修改的地方做好准备.

如何为那些不太明显的概念建模

显式的约束

约束是模型概念中非常重要的类别, 它们通常是隐含的, 将它们显式地表现出来可以极大地提高设计质量.

将约束条件提取到单独的方法中, 可以通过方法名来表达约束的含义, 从而在设计中显式地表现出这条约束. 这种方式也为约束的扩展提供了空间. 调用者更简单一些, 并且只专注于处理自己的任务, 而约束条件可以根据需要进行扩展.

有时约束条件是无法用单独的方法来轻松表达的, 它可能会调用一些信息, 但对于对象的主要职责而言这些信息毫无用处, 这种约束可能就不适合放到现有对象中.

表明约束的存在扰乱其宿主对象Host Object的设计的一些警告信号:

  1. 计算约束所需的数据从定义上看并不属于这个对象.
  2. 相关或相似的约束在多个对象中出现, 造成代码重复或导致不属于同一族的对象之间产生了继承关系.
  3. 很多设计和需求讨论是围绕着这些约束进行的, 而在代码实现中, 约束却隐藏在过程代码中.

如果约束的存在掩盖了对象的基本职责, 或者如果约束在领域中非常突出但在模型中却不明显, 那么就可以将其提取到一个显式的对象中, 甚至可以把它建模为一个对象和关系的集合.

将过程建模为领域对象

在领域中的过程, 必须在模型中表示出来, 否则会使对象设计变得笨拙.

Service是显式表达这种过程的一种方式, 同时它还将异常复杂的算法封装起来.

如果过程的执行有多种方式, 可以将算法本身或其中的关键部分放到一个单独的对象中, 这样不同的过程就变成了选择不同的对象, 么李哥对象都表示一种不同的Strategy.

如果过程经常被领域专家提起, 则该过程应该被显式表达出来. 如果过程仅仅被当作计算机程序机制的一部分, 那么该过程应该被隐藏起来.

约束和过程是两大类模型概念, 一旦被视为模型元素, 可以让设计更为清晰.

模式: Specification

Specification规格提供了用于表达特定类型规则的精确方式, 它把这些规则从条件逻辑中提取出来, 并在模型中把它们显式地表示出来.

业务规则通常不适合作为Entity或Value Object的职责, 而且规则的变化和组合也会掩盖领域对象的基本含义. 但是将规则移出领域层, 那么领域代码不再表达模型了.

逻辑编程提供了谓词这种可分离, 可组合的规则对象, 但用对象完全实现是很麻烦的. 同时这种概念过于通用, 在表达设计意图方面, 它的针对性不如专门的设计好.

并不真正需要完全实现逻辑编程, 大部分规则可归类为几种特定的情况. 可以借用谓词概念来创建可计算出布尔值的特殊对象. 那些难于控制的测试方法可以巧妙地扩展出自己的对象, 它们都是一些小的真值测试, 可以提取到单独的Value Object中. 这些对象就是Specification规格.

为特殊目的创建谓词形式的显式的Value Object. Specification规格是一个谓词, 可以用来确定对象是否满足某些标准.

Specification规格将规则保留在领域层. 由于规格是一个完备的对象, 这种设计能够更加清晰地反映模型. 利用Factory工厂可以对规格进行配置.

Specification的应用和实现

Specification规格最有价值的地方在于它可以将看起来完全不同的应用功能统一起来:

  1. 验证对象, 检查它是否能满足某些需求或是否已经为实现某个目标做好了准备.
  2. 从集合中选择一个对象.
  3. 指定在创建新对象时必须满足某种需求.

这三种用法从概念层面上来讲是相同的. 如果没有Specification规格模式, 相同的规则可能会表现为不同的形式, 甚至是矛盾的形式, 这样会丧失概念上的统一性.

验证

Specification规格最简单用法是验证. 这种用法最能直观地展示出它的概念.

选择或查询

根据某些标准从对象集合中选择一个子集, Specification规格概念可以在此应用, 但实现问题会有所不同.

在典型的业务系统中, 数据存储在关系数据库中. Specification规格和Repository非常适合搭配, 能充分利用数据库的强大查询能力, 同时又能保留Specification规格模式. Repository作为一种构造块机制, 提供了对领域对象的查询访问, 并且把数据库接口封装起来.

将SQL查询代码放置在Repository中, 而应该使用哪个查询这由Specification来控制. Specification规格中没有定义完整的规则, 但指明了什么条件构成规则.

可以通过SQL查询取出更多的数据, 然后使用Specification规格进行筛选. 这种降低性能来实现更好的职责分离的代价完全取决于环境因素.

这种情况本人建议不用使用Specification规格来解决选择或查询问题.

因为如果查询条件多且规则复杂, 那要写很多的SQL查询语句. 不如直接使用Value Object来封装查询所需的数据, 直接编写复杂的SQL语句.

或者可以直接使用专门为搜索的中间件, 如ElasticSearch等等.

根据需求来创建

要创建或重新配置满足Specification规格的全新对象或对象集合, 可以使用描述性的Specification规格来定义生成器的接口, 方法有如下优点:

  1. 生成器的实现与接口分离. Specification规格声明了输出的需求, 但没有定义如何得到输出结果.
  2. 接口把规则显式地表示出来, 开发人员无需理解所有操作细节即可知道生成器会产生什么结果.
  3. 接口更为灵活, 因为需求由客户给出, 生成器的唯一职责就是实现Specification中的要求.
  4. 接口更加便于测试, 因为接口显式地定义了生成器的输入, 同时也可用来验证输出.

根据要求来创建可以是从头创建全新对象, 也可以是配置已有对象来满足Specification规格.

柔性设计

软件的最终目的是为用户服务, 但首先必须为开发人员服务. 随着程序的演变, 开发人员将重新安排并重新每个部分, 当具有复杂行为的软件缺乏良好的设计时, 重构或元素的组合会变得困难. 一旦开发人员不能十分肯定地预知计算的全部含意, 就会出现重复.

如果软件没有一个条理分明的设计, 那么开发人员不仅不愿意仔细地分析代码, 更不愿意修改代码, 因为修改代码会产生问题: 要么加重了代码的混乱状态, 要么由于某种未预料到的依赖而破坏了某些东西. 这种不稳定性导致很难开发出丰富的功能, 而且限制了重构和迭代式的精化.

为了使项目能够随着开发工作的进行加速前进, 而不会由于它自己的老化停滞不前, 设计必须要让人们乐于使用, 而且易于做出修改. 这就是柔性设计supple design.

柔性设计是对深层建模的补充. 通过一次次的迭代循环和重构得到更深刻的理解.

过度设计如过多的抽象层和间接设计常常成为项目的绊脚石. 必须坚持模型驱动的设计方法, 还要坚持适当严格的设计风格, 才能把创建的元素装配到复杂系统中而且在装配之后仍然能够理解它们.

设计必须为客户开发人员服务, 客户开发人员可以灵活地使用一个最小化的, 松散耦合的概念集合, 并用这些概念表示领域中的众多场景. 设计元素自然地组合到一起, 结果是健壮的, 是清晰的, 是可预知的.

设计必须为修改代码的开发人员服务, 必须按照领域深层模型的轮廓进行设计, 以便大部分修改都可以灵活地完成.

当复杂性阻碍了项目前进时, 就需要仔细修改最关键, 最复杂的地方, 使之变成一个柔性设计, 这样才能突破复杂性的限制, 而不会陷入遗留代码维护的麻烦中.

模式: Intention-Revealing Interfaces

如果开发人员为了使用一个组件而必须要去研究它的实现, 那么就失去了封装价值. 如果使用这个组件的新开发者不得不根据实现来推测其用途, 那么他推测出来的可能不是其主要用途. 这样设计的概念基础已经被误用.

当把概念显式地建模为类或方法时, 必须为这些程序元素赋予一个能够反映其概念的名字. 类和方法的名称为开发人员之间的沟通提供便利, 也能够改善系统的抽象.

通过Intention-Revealing Selector释意命名选择器来选择方法的名称, 使名称表达出目的. 设计中的所有公共元素共同构成了接口, 每个元素的名称都提供了揭示设计意图的机会. 类型名称, 方法名称和产生名称组合在一起, 共同形成了一个Intention-Revealing Interface 释意接口.

在命名类和操作时要描述它们的效果和目的, 而不要表露时通过何种方式达到目的的, 这样可以使客户开发人员不必去理解内部细节. 这些名称应该与Ubiquitous Language保持一致, 以便团队成员可以迅速推断出它们的意义. 在创建一个行为之前先为它编写一个测试, 这样可以促使你站在客户开发人员的角度上来.

所有复杂的机制都应该封装到抽象接口的后面, 接口只表明意图, 而不表明方式.

在领域的公共接口中, 可以把关系和规则表述出来, 但不要说明规则是如何实施的; 可以把事件和动作描述出来, 但不要描述它们是如何执行的; 可以给出方程式, 但不要给出解方程式的数学方法; 可以提出问题, 但不要给出获取答案的方法.

模式: Side-Effect-Free Function

可以宽泛地把操作分为两个大的类别: 命令和查询. 命令是修改系统的操作, 查询是从系统获取信息. 在计算机科学中, 任何对系统状态产生的影响都叫做副作用. 这里把含义缩小为: 任何对未来操作产生影响的系统状态改变都可以称为副作用.

多个规则的相互作用或计算的组合所产生的结果是很难预测的. 开发人员在调用一个操作时, 为了预测操作的结果, 必须理解它的实现以及它所调用的方法的实现. 如果开发人员需要了解接口的实现和代码, 那么接口的抽象作用就受到了限制. 如果没有了可以安全地预见到结果的抽象, 开发人员必须限制组合数量, 这就限制了系统行为的丰富性.

返回结果而不产生副作用的操作称为函数. 函数可以被多次调用, 每次都返回相同的值. 一个函数可以调用其他函数, 而不必担心嵌套的深度, 函数更易于测试, 可以降低风险.

有两种方法可以减少命令产生的问题:

  1. 可以把命令和查询严格地放在不同的操作中. 确保导致状态改变的方法不返回领域数据, 并尽可能保持简单. 在不引起任何可观测到的副作用的方法中执行所有查询和计算.
  2. 总有一些替代的模型和设计, 其不要求对现有对象做任何修改, 它们创建并返回一个Value Object, 用于表示计算结果.

Value Object是不可变的, 意味着除了在创建期间调用的初始化程序之外, 它们的所有操作都是函数.

尽可能把程序的逻辑放到函数中, 因为函数只返回结果而不产生明显的副作用. 严格地把命令(引起明显状态改变的方法)隔离到不返回领域信息的, 非常简单的操作中. 当发现一个非常适合承担复杂逻辑职责的概念时, 就可以把这个复杂逻辑移到Value Object中, 这样可以进一步控制副作用.

Side-Effect-Free Function在不变的Value Object中, 允许安全地对多个操作进行组合. 当通过Intention-Revealing Interface把一个Function呈现出来的时候, 开发人员可以在无需理解其实现细节的情况下使用它.

模式: Assertion

把复杂的计算封装到Side-Effect-Free Function中可以简化问题, 但实体仍然有一些有副作用的命令, 使用这些Entity的人必须了解使用这些命令的后果. 在这种情况下, 使用Assertion断言可以把副作用明确地表示出来, 使它们更易于处理.

如果操作的副作用仅仅是由它们的实现隐式定义的, 那么在一个具有大量互相调用关系的系统中, 起因和结果会变得一团糟. 理解程序的唯一方式就是沿着分支路径来跟踪程序的执行. 封装完全失去了价值, 跟踪具体的执行也使抽象失去了意义.

把操作的后置条件(Assertion断言)和类及Aggregate的固定规则表述清楚, 如果在编程语言中不能之间编写Assertion, 那么就把它们编写层自动的单元测试, 还可以把它们写到文档或图中.

寻找概念上内聚的模型, 以便使开发人员更容易推断出预期的Assertion, 从而加快学习过程并避免代码矛盾. Assertion只声明状态而不声明过程, 很容易编写测试.

把固定规则, 前置条件和后置条件清楚地表述出来, 这样开发人员能够理解使用一个操作或对象的后果.

Intention-Revealing Interface清楚地表明了用途, Side-Effect-Free Function和Assertion能够更准确地预测结果, 因此封装和抽象更加安全.

模式: Conceptual Contour

如果把模型或设计的所有元素都放在一个整体的大结构中, 那么它们的功能会发生重复. 外部接口无法给出客户可能关心的全部信息. 由于不同的概念被混合在一起, 它们的意义变得很难理解.

另一方面, 把类和方法分解开可能是毫无意义的, 这会使客户端更复杂, 迫使客户端去理解各个细微部分是如何组合在一起的. 更糟糕的是, 有的概念可能会完全丢失. 粒度的大小并不是唯一要考虑的问题, 还要考虑粒度在哪种场合下使用的.

领域中一定存在某种十分复杂的原理, 也隐含着某种逻辑的一致性, 这是领域建模的意义. 如果新的发现与模型不符, 就需要对模型进行重构, 以获取更深层的理解.

随着代码不断适应新理解的概念或需求, Conceptual Contour概念轮廓逐渐形成.

高内聚低耦合的设计原则既适用于代码, 也适用于概念, 寻找在概念上有意义的单一, 可以使得设计既灵活又易懂.

把设计元素(操作, 接口, 类和Aggregate)分解为内聚的单元, 在这个过程中, 对领域中一切重要划分的直观认识也要考虑在内. 在连续的重构过程中观察发生变化和保证稳定的规律性, 并寻找能够解释这些变化模式的底层Conceptual Contour概念轮廓,使模型与领域中那些一致的方面相匹配.

设计虽然按照Conceptual Contour概念轮廓进行, 但仍需要修改和重构. 当连续的重构只是做出一些局部修改而不是对模型的概念产生大范围影响时, 这就是模型已经与领域相吻合的信号. 如果遇到一个需求, 要求必须大幅度地修改对象和方法的划分, 那么我们对领域的理解还需精化.

模式: Standalone Class

互相依赖使得模型和设计变得难以理解, 测试和维护, 而且互相依赖很容易越积越多.

Module和Aggregate的目的都是为了限制互相依赖的关系网. 即使在Module内部, 设计也会随着依赖关系的增加变得越来越难以理解, 加重了思考负担, 限制了开发人员能处理的设计复杂度. 隐式概念比显式引用增加的负担更大.

可以将模型一直精炼下去, 直到每个剩下的概念关系都表示出概念的基本含义为止. 在一个重要的子集中, 依赖关系的个数可以减小到零, 这样就得到一个完全独立的类Standalone Class, 它只有很少的几个基本类型和基础库概念.

应该对每个依赖关系提出质疑, 直到证实它确实表述对象的基本概念为止. 检查依赖关系的过程从提取模型概念本身开始, 然后需要注意每个独立的关联和操作, 仔细选择模型和设计能够大幅减少依赖关系, 常常能减少到零.

低耦合说对象设计的一个基本要素, 尽一切可能保持低耦合, 把其他所有无关概念提取到对象之外. 这样类就完全独立了, 使得我们可以单独地研究和理解它. 每个这样独立类都极大地减轻了因理解Module而带来的负担.

目标不是消除所有依赖, 而是消除所有不重要的依赖. 当无法消除所有的依赖关系时, 每清除一个依赖对开发人员都是一种解脱, 使他们能够集中精力处理剩下的依赖关系.

尽力把最复杂的计算提取到Standalone Class独立的类, 实现此目的的一种方法是从存在大量依赖的类中将Value Object建模出来.

低耦合是减少概念过载的最基本方法, 独立的类书低耦合的极致.

模式: Closure Of Operation

Closure Of Operation闭合操作, 名字来源于数学概念体系, 可用来定义一种不涉及其他任何概念的运算. 闭合的性质极大地简化了对操作的理解, 而且闭合操作的链接与组合也很容易理解.

在适当的情况下, 在定义操作时让它的返回类型与其产生的类型相同, 如果实现者implementer的状态在计算中会被用到, 那么实现者实际上就是操作的一个产生, 因此参数和返回值应该与实现者有相同的类型. 这样的操作就是在该类型的实例集合中的闭合操作. 闭合操作提供了一个高层接口, 同时又不会引入对其他概念的任何依赖.

Closure Of Operation闭合操作模式更常用于Value Object的操作. 一个操作可能是在某一抽象类型下的闭合操作, 具体的参数可能是不同的具体类型.

参数类型与实现者的类型一致但返回类型不同, 或者返回类型与接收者receiver的类型相同但参数类型不同, 这些操作称为半个闭合操作. 当没有形成闭合操作的那个多出来的类型是基本类型或基本库类时, 它几乎与Closure Of Operation闭合操作一样减轻了思考负担.

声明式设计

声明式设计通常是指一种编程方式, 把程序或程序的一部分写成一种可执行的规格. 使用声明式设计时, 软件实际上是由一些非常精确的属性来控制的.

从模型属性的声明来生成可运行的程序是Model-Driven Design的理想目标, 但在实践中这种方法也有自己的缺陷:

  1. 声明式语言不足以表达一切所需的东西, 它把软件束缚在一个由自动部分构成的框架之内, 使软件很难扩展到这个框架之外.
  2. 代码生成技术破坏了迭代循环, 它把生成的代码合并到手写的代码中, 使得代码重新生成具有巨大的破坏作用.

基于规则的程序原则上是声明式的, 但大多数系统都有一些用于性能优化的控制谓词control predicate, 这种控制代码引入了副作用, 这样行为就不再完全由声明式规则来控制了. 添加, 删除规则或重新排序可能导致预料不到的错误结果. 因此编写逻辑的程序必须确保代码的效果是显而易见的, 就像对象程序员做的那样.

很多声明式方法被开发人员有意或无意忽略之后遭到破坏. 当系统很难使用或限制过多时, 就会发生这种情况. 为了获得声明式程序的好处, 每个人都必须遵守框架的规则.

声明式设计发挥的最大价值是用一个访问非常窄的框架来自动处理设计中某个特别单调且易出错的方面, 如持久化和对象关系映射. 最好的声明式设计能够使开发人员不必去做那些单调乏味的工作, 同时又完全不限制他们的设计自由.

领域特定语言

领域特定语言是一种有趣的方法, 有时也是一种声明式语言. 采用这种编码风格时, 客户端代码是用一种专门为特定领域的特定模型定制的语言编写的, 然后程序通常会被编译成传统的面向对象语言, 由一个类库为这些术语提供实现.

这样的语言, 程序可能有极强的表达能力, 并且与Ubiquitous Language紧密结合.

但是为了精化模型, 开发人员需要修改领域语言, 可能涉及到修改语法声明和其他语言解释, 以及修改底层类库, 此时必须冷静地评估团队当前的技术水平, 以及将来维护团队可能的技术水平. 此外用同一种语言实现的应用程序和模型之间是无缝的.

另一个缺点是当模型被修改时, 很难对客户端代码进行重构, 使之与修改之后的模型及与其相关的领域特定语言保持一致.

有一种不同的范式能够比对象更好地实现领域特定语言, 即函数式编程.

声明式设计风格

一旦设计中有了Intention-Revealing Interface, Side-Effect-Free Function和Assertion, 那么就具备了使用声明式设计的条件.

柔性设计使得客户端代码可以使用声式的设计风格.

用声明式的风格来扩展Specification规格. 使用逻辑运算对Specification进行组合. Specification是由谓词predicate概念演变来的, 因此可以使用AND, OR和NO等运算进行组合和修改. 这些逻辑运算都是谓词下的闭合操作, 因此Specification组合也是Closure Of Operation.

切入问题的角度

分割子领域

无法一下子就能处理好整个设计, 而需要一步一步地进行. 可以从系统的某些方面看出适合哪种方法处理, 那么就把它们提取出来加以处理. 随着步骤的进行, 新模型更整洁了, 而且剩下的部分也更小, 更清晰了.

重点突击某个部分, 使设计的一个部分真正变得灵活起来, 这比分散精力泛泛地处理整个系统要有用的多.

尽可能利用已有的形式

不能从头创建一个严密的概念框架, 而是对领域中已建立很久的概念系统加以修改和利用.

有很多这样的正式概念框架, 如数学, 很多领域都设计数学, 可以用基本数学概念把一些复杂的问题提取出来. 数学可以通过情绪的规则进行组合, 并很容易理解.

总结

柔性设计可以极大地提升软件处理变更和复杂性的能力. 柔性设计在很多程度上取决于详细的建模和设计决策. 柔性设计的影响可能远远超越某个特定的建模和设计问题.

应用分析模式

深层模型和柔性设计并非唾手可得, 要想取得进展, 必须学习大量领域知识并进行充分的讨论, 还需要经历大量的尝试和失败.

经验丰富的开发人员研究问题时, 如果遇到相似或熟悉的领域问题, 会使用已有的模式. 利用这些模式可以避免一些代价高昂的尝试和失败过程, 而直接从一个已经具有良好表达力和易实现的模型开始工作, 并解决了一些可能难于学习的微妙问题. 可以从这样的一个起点来重构和试验. 然而它们并不是现成的解决方案,

分析模式是一种概念集合, 用来表示业务建模中的常见结构, 为迭代开发过程提供了一个非常有价值的起点. 分析模式并不是技术解决方案, 只是参考, 用来指导人们设计特定领域中的模型.

在不考虑实际设计的情况下进行单纯的分析是有缺陷的. 分析模式的最大作用是借鉴其他项目的经验, 把那些项目中有关设计方向和实现结果的广泛讨论与当前模型的理解结合起来. 脱离具体的上下文来讨论模型思想不但难以落地, 而且还会造成分析与设计验证脱节的分析.

分析模式提供了有价值的线索, 提供了明确抽象的词汇, 还可以指导实现, 省去很多麻烦. 应该吧所有分析模式的知识融入到知识消化和重构的过程中, 从而形成更深刻的理解, 并促进开发.

使用众所周知的分析模式中的术语是, 不管其表面形式的变化有多大, 都不要改变它说表示的基本概念. 因为: 一是模式中蕴含的基本概念将帮助我们避免问题; 二是使用被广泛理解或至少被明确解释的术语可以增强Ubiquitous Language.

分析模式专注于一些最关键和最艰难的决策, 并阐明了各种替代和选择方案, 提前预测了一些后期结果.

将设计模式应用于模型

虽然设计模式是从纯技术角度描述的, 但其中的一部分元素在更广泛的领域和设计上下文中也适用, 因为这些元素所对应的基本概念在很多领域中都会出现.

为了在领域驱动设计中充分利用这些模式, 必须同时从两个角度看待它们: 从代码的角度来看它们是技术设计模式, 从模型的角度来看它们是概念模式.

模式: Strategy (Policy)

需要把过程中的易变部分提取到模型的一个单独的策略对象中, 将规则与它所控制的行为区分开. 按照Strategy设计模式来实现规则或可替换的过程, 策略对象的多个版本表示了完成过程的不同方式.

在领域层中使用技术设计模式时, 必须认识到该模式是领域模型的一个概念.

设计模式的结论也完全适用于领域层.

模式: Composite

当在领域中应用任何一种设计模式时, 首先关注的问题应该是模式的意图是否确实符合领域概念.

定义一个把Composite的所有成员都包含在内的抽象类型, 在容器上实现那些查询方法时, 这些方法返回由容器内容所汇总的信息. 而叶节点则基于它们自己的值来实现这些方法, 客户端只需使用抽象类型, 无需区分叶和容器.

Composite是结构层面上的模式, 使在每个结构层上都提供了相同的行为.

Flyweight模式

Flyweight模式并不适用于领域模型, 当一个Value Object集合被多次使用的时候, 那么把它们实现为Flyweight可能是有意义的. 这是一个适用于Value Object但不适用于Entity的实现选择.

把设计模式用作领域模式的唯一要求是这些模式能够描述关于概念领域的一些事情, 而不仅仅是作为解决技术问题的解决方案.

通过重构得到更深层的理解

通过重构得到更深层的理解是一个涉及很多方面的过程. 有三件事是必须要关注的:

  1. 以领域为本
  2. 用一种不同的方式来看待事物
  3. 始终坚持与领域专家对话

传统的在代码层面的重构不是重构过程的全部.

开始重构

重构的原因:

  1. 为了解决代码中的问题, 如一段复杂或笨拙的代码.
  2. 领域模型中缺少一个概念, 或某个关系发生了错误.
  3. 领域模型的语言没有与领域专家保持一致, 或者新需求不能被自然地添加到模型中.
  4. 开发人员通过学习获得了深刻的理解. 发现了一个得到更清晰或更有用的模型的机会.

探索团队

找到问题的根源后, 下一步是要找到一种能够使模型表达更清楚和更自然的改进方案.

发起者挑选善于思考并理解领域或掌握建模技巧的开发人员一起工作, 如果涉及一些难以捉摸的问题, 还需领域专家的加入. 这个4到5人的小组在会议室进行头脑风暴, 发现令人满意的新思路后, 回去编码. 几天之后, 这个小组再次重复上面过程.

要想保证这个过程的效率, 需要注意几个关键事项:

  1. 自主决定. 可以随时组成小团队来研究某个设计问题. 这个团队只工作几天, 然后就可以解散了.
  2. 注意范围和休息. 在几天内召开的两三次短会就应该能够产生一些值得尝试的设计. 工作拖得太长没有好处. 如果讨论毫无进展, 可能一次讨论的内容太多, 可选一个较小的设计方面, 集中讨论它.
  3. 练习使用Ubiquitous Language. 参与头脑风暴会议是练习和精化Ubiquitous Language的好机会. 原来的开发人员可以得到更完善的Ubiquitous Language并反映到编码中.

成熟的头脑风暴是灵活机动, 不拘泥于形式的, 而且具有令人难以置信的高效率.

借鉴先前的经验

没有必要去做一些无谓的重复工作. 头脑风暴过程可以收集来自各个方面的想法, 并与已有知识经验结合起来.

可以从书籍和领域自身的其他知识源获得思路. 相关领域的人员可能已经把模型概念很好地组织在一起, 并发现一些有用的抽象. 把这些知识结合到知识消化过程中, 可以更快速地得到更丰富的结果, 而且这个结果也更为领域专家所熟悉.

有时可以从分析模式中汲取他人的经验. 分析模式可以提供精细的模型概念, 并帮助避免很多错误. 分析模式只是为知识消化过程提供了一些帮助和参考.

当设计模式既符合实现需求, 又符合模型概念时, 通常可以在领域层中应用这些模式.

当一种常见的形式体系与领域的某个部分非常符合时, 可以把这个部分提取出来, 并根据它来修改形式系统的规则. 这样可以尝试非常简练且易于理解的模型.

针对开发人员的设计

软件不仅仅是为用户提供的, 也是为开发人员提供的. 开发人员必须把它们编写的代码与系统的其他部分集成到一起. 开发人员应该通过重构得到更深层的理解, 这样既能过实现柔性设计, 也能够从设计中获益.

柔性设计能够清楚地表明它的意图. 这样的设计使人们很容易看出代码的运行效果, 也很容易预计修改代码的结果. 柔性设计主要通过减少依赖性和副作用来减轻思考负担, 是以深层次的领域模型为基础的, 使得需要修改的地方能够保持很高的灵活性.

重构的时机

持续重构是一种最佳实践. 等到完全证明修改的合理性之后才去修改, 那么就限制了重构. 软件开发并不是一个完全预料到后果的过程.

必须始终坚持把通过重构得到更深理解作为工作的一部分.

当发生以下情况时, 就应该进行重构了:

  1. 设计没有表达出团队对领域的最新理解.
  2. 重要的概念被隐藏在设计中了.
  3. 发现了一个能令某个重要的设计部分变得更灵活的机会.

虽然应该有这样一种积极的态度, 但并不意味着可以随随便便做任何修改. 在发布前一天, 就不要进行重构了. 不要引入一些只顾炫耀技术而没有解决领域核心问题的设计. 如果设计不能说服领域专家们去使用它, 那么就不要引入它.

危机就是机遇

在对模型进行一段时间稳定的改进后, 可能突然有所顿悟, 而这会改变模型中的一切. 这些突破不会每天都发生, 然而很大一部分深层模型和柔性设计都来自这些突破.

这样的情况看起来不像是机遇, 而更像是危机. 比如突然发现模型中的一些明显的缺陷, 模型在表达方面显示出一个很大的漏洞, 或存在一些没有表达清楚的关键区域, 或有些描述是完全错误的.

这些都表明团队对模型的理解已经达到了一个新的水平, 可以站在更高的层次发现原有模型的缺点, 可以从这种角度构思一个更好的模型.

通过重构得到更深层理解是一个持续不断的过程. 发现一些隐含的概念并把它们明确地表示出来. 有些设计部分变得更具有柔性, 或许还采用了声明式的风格. 开发工作到了突破的边缘, 然后跨越这条界线, 得到更深层的模型, 接下来又重新开始了稳步的改进过程.

战略设计

随着系统的增长, 系统会变得越来越复杂, 当无法通过分析对象来理解系统的时候, 就需要掌握一些操纵和理解大模型的技术.

如果企业系统的整体业务模型太大且复杂, 会难以管理, 很难把它作为一个整体来理解, 这时必须在概念和实现上把系统分解为较小的部分. 问题在于, 如何保证实现模块化的同时, 不失去集成所具备的好处, 从而使系统的不同部分能够互操作, 以便协调各种业务操作. 如果用临时拼凑的接口把一组小的, 各自不同的子系统集成在一起, 又不具备解决企业级问题的能力, 且在每个集成点上都有可能出现不一致. 通过采用系统的, 不断演变的设计策略, 可以避免这两种极端问题.

战略设计原则必须指导设计决策, 以便减少各个部分之间的相互依赖, 在使设计意图更为清晰的同时又不失去关键的互操作性和协同性. 战略设计原则必须把模型的重点放在捕获系统的概念核心. 为了帮助实现目标, 有三大原则.

上下文

上下文是最根本的原则. 无论模型大小, 必须在逻辑上一致, 不能有相互矛盾或重叠的定义. 通过为每个模型显式地定义一个Bounded Context, 然后在必要的情况下定义它与其他上下文的关系, 建模人员就可以避免模型变得缠杂不清.

精炼

通过精炼可以减少混乱, 并且把注意力集中到正确的地方. 应把注意力集中到突出系统中最有价值和最特殊的方面, 可以使系统不会偏离预期方向. 战略精炼可以使大的模型保持清醒.

大型结构

大型结构是用来描述整个系统的, 如果不贯彻系统主旨来应用一些系统级的设计元素和模式的话, 关系可能非常混乱. 当需要的时候, 应该创造新的结构或修改结构, 但均需遵循演化顺序evolving order. 一些大型结构能够使设计保持一致性, 从而加速开发, 并提高集成度.

这三种原则结合起来使用将发挥更大的力量, 遵守这些原则可以创建出好的设计.

大型结构能够保持各个不同部分之间的一致性, 有助于这些部分的集成. 精炼能够帮助理解各个部分之间的复杂关系, 同时保持整体视图的清晰. 上下文Bounded Context使我们能够在不同的部分中进行工作, 而不会破坏模型或是无意间导致模型的分裂.

保持模型的完整性

模型最基本的要求是保持内部一致统一, 术语具有相同的意义, 且不包含互相矛盾的规则. 但大型系统领域模型的完全统一既不可行, 也不划算, 必须慎重地选择系统哪些部分可以分开, 以及它们之间是什么关系, 需要用一些方法来保持模型关键部分的高度统一, 这只能通过有意识的设计决策和建立特定过程才能实现.

将一个大型项目中的所有软件统一到单个模型中, 会有以下的风险:

  1. 一次尝试对遗留系统做过多的替换.
  2. 大项目可能会陷入困境, 因为协调的开销太大, 超出了这些项目的能力范围.
  3. 具有特殊需求的应用程序可能不得不使用无法满足需求的模型, 而只能将这些无法满足的行为放到其他地方.
  4. 试图用一个模型来满足所有人的需求可能会导致模型中包含过于复杂的选择, 因而很难使用.
  5. 权力上的划分和管理级别的不同也可能要求把模型分开, 而且不同模型的出现可能是团队组织和开发过程导致的结果.

既然无法维护一个涵盖整个企业的统一模型, 那就不要再受到这种思路的限制. 通过预先决定什么应该统一, 并实际认识到什么不能统一, 就能创建一个清晰的, 共同的视图. 确定了这些之后, 就可以着手开始工作, 以保证那些需要统一的部分保持一致, 不需要统一的部分不会引起混乱或破坏模型.

需要用一种方式来标记出不同模型之间的边界和关系, 需要有意识地选择一种策略, 并一致地遵守它.

模式: Bounded Context

任何大型项目都会存在多个模型, 而当基于不同模型的代码被组合在一起后, 软件就会出现bug, 变得不可靠且难以理解, 团队成员之间的沟通变得混乱.

模型混乱的问题最终会在代码不能正常运行时暴露出来, 但问题的根源在于团队的组织方式和成员的交流方法.

明确地定义模型所应用的上下文. 根据团队的组织, 软件系统的各个部分的用法以及物理表现(代码和数据库模式等)来设置模型的边界. 在这些边界中严格保持模型的一致性, 而不要受到边界之外问题的干扰和混淆.

注意: Bounded Context不是Module

它们是具有不同动机的不同模式. 在一个模型中用Module来组织元素, 不一定要表达划分Context的意图.

识别Bounded Context中的不一致

很多征兆都可能表明模型中出现了差异, 最明显的是已编码的接口不匹配. 采用了自动测试的Continuous Integration可以帮助捕捉到这类问题. 语言上的混乱往往是一种早期的警告信号.

将不同模型的元素组合到一起可能会引发两类问题: 重复的概念假同源.

重复的概念是指两个模型元素实际上表示同一种概念. 每当这个概念的信息发生变化时, 都必须更新两个地方.

假同源是指使用相同术语的两个人认为他们是在谈论同一件事情, 但实际上并不是这样. 假同源会导致开发团队互相干扰对方的代码, 也可能导致数据库中含有奇怪的矛盾, 还会引起团队沟通的混淆.

当发现这些问题时, 团队必须要做出相应的决定, 可能需要将模型重新整合为一体, 并加强用来预防模型分裂的过程.

模式: Continuous Integration

当很多人在同一个Bounded Context中工作时, 模型很容易发生分裂.

Continuous Integration持续集成是指把一个上下文中的所有工作足够频繁地合并到一起, 并使它们保持一致, 以便当模型发生分裂时, 可以迅速发现并纠正问题.

Continuous Integration有两个级别的操作: 模型概念的集成和实现的集成.

团队成员之间通过经常沟通来保证概念的集成, 团队必须对不断变化的模型形成一个共同的理解. 最基本的办法是对Ubiquitous Language多加锤炼.

建立一个把所有代码和其他实现工件频繁地合并到一起的过程, 并通过自动化测试来快速查明模型的分裂问题. 严格坚持使用Ubiquitous Language, 以便在不同人的头脑中演变出不同的概念时, 使所有人对模型都能达成一个共识.

不要在持续集成中做一些不必要的工作. Continuous Integration只有在Bounded Context中才是重要的. 相邻的Context中的设计问题不必以同一个步调来处理.

模式: Context Map

只有一个Bounded Context并不能提供全局视图, 其他模型的上下文可能仍不清楚而且还在不断变化.

其他团队中的人员并不十分清楚Context的边界, 他们会不知不觉地做出一些更改, 从而使边界变得模糊或者使互连变得复杂. 当不同的上下文必须互相连接时, 它们可能会相互重叠.

Bounded Context之间的代码重用是很危险的, 应该避免. 功能和数据的集成必须要通过转换去实现. 通过定义不同上下文之间的关系, 并在项目中创建一个所有模型上下文的全局视图Context Map, 可以减少混乱.

Context Map位于项目管理和软件设计的重叠部分, 往往按照团队组织的轮廓来划定边界. 紧密协作的人会很自然地共享一个模型上下文, 不同团队的人员将使用不同的上下文.

识别在项目中起作用的每个模型, 并定义其Bounded Context, 包括非面向对象子系统的隐含模型. 为每个Bounded Context命名, 并把名称添加到Ubiquitous Language中. 描述模型之间的联系点, 明确所有通信需要的转换, 并突出任何共享的内容. 先将当前的情况描绘出来, 以后再做改变.

在每个Bounded Context中, 都将有一种一致的Ubiquitous Language的方言. 需要把Bounded Context的名称添加到方言中, 这样只要通过明确Context就可以清楚地讨论任意设计部分的模型.

Context Map无需拘泥于任何特定的文档格式, 必须在所有项目人员之间共享, 并被他们理解, 必须为每个Bounded Context提供一个明确的名称, 而且必须阐明联系点和它们的本质.

如果发现模型产生分裂, 模型完全混乱且包含不一致时, 应该把描述工作停下来, 然后从精确的全局角度来解决这些混乱点. 最重要的任务是画出一个清晰的Context Map, 不要因为修复必要的问题而重组整个结构, 只需要修改那些明显的矛盾即可, 直到得到一个明确的Context Map. 一旦获得了一致的Context Map, 会看到需要修改的地方, 在经过深思熟虑后, 可以调整团队的组织或设计.

测试Context的边界

对各个Bounded Context的联系点的测试特别重要. 这些测试有助于解决转换时所存在的一些细微问题以及弥补边界沟通上存在的不足. 测试充当了早期的报警系统, 特别是在必须信赖模型细节却无法控制时, 测试能让我们感到放心.

Context Map的组织和文档化

Bounded Context应该有名称, 以便可以讨论它们, 名称应该被添加到Ubiquitous Language中.

每个人都应该知道边界在哪里, 而且应该能够分辨出任何代码段的Context或任何情况的Context. 有很多方式可以满足这种需求, 这取决于团队的文化.比如把不同上下文的代码隔离到不同的Module中, 但必须使用简单且不会产生混淆的跟踪机制.

同样重要的是以一种适当的形式来传达概念边界, 以使团队中的每个人都能以相同的方式来理解它们.

Bounded Context之间的关系

将两个模型关联起来的策略模式有很多, 它们有着两个目的: 一是为成功地组织开发工作设定目标; 二是为描述现有组织提供术语.

现有关系可能与这些模式中的某一种很接近, 这种情况下可以使用模式的术语来描述关系, 但需要重视差异之处.

现有关系很混乱或过于复杂, 需要考虑重组, 这种情况下这些模式提供了应对各种不同情况的选择. 这些模式的主要区别包括对另一个模型的控制程度, 两个团队的合作水平和合作类型, 以及特性和数据的集成程度.

模式: Shared Kernel

当不同团队开发一些紧密相关的应用程序时, 如果团队之间不进行协调, 即使短时间内能够取得快速进展, 但他们开发出的产品可能无法结合到一起. 最后可能不得不耗费大量精力在转换层上, 并且频繁地进行改动, 不如一开始就使用Continuous Integration那么省心省力, 同时也造成重复工作, 并且无法实现公共的Ubiquitous Language所带来的好处.

从领域模型中选出两个团队都同意共享一个子集. 除了这个模型子集外, 还包括与该模型部分相关的代码子集或数据库设计的子集. 这部分明确共享的内容具有特殊的地位, 一个团队在没与另一个团队商量的情况下不应擅自更改它.

功能系统要经常进行集成, 但集成的频率应该比团队内的Continuous Integration的频率低一些, 在进行集成的时候, 两个团队都要运行测试.

Shared Kernel共享内核不能像其他设计部分那样自由更改, 在做决定时需要与另一个团队协商, 共享内核中必须集成自动测试套件. 修改共享内核时, 必须要通过两个团队的所有测试.

Shared Kernel通常是Core Domain核心领域, 或是一组Generic Subdomain通用子领域, 也可能二者兼有. 它可以是两个团队都需要的任何一部分模型. 使用Shared Kernel的目的是减少重复(并不是消除重复, 因为只有在一个Bounded Context中才能消除重复), 并使两个子系统之间的集成变得相对容易一些.

模式: Customer/Supplier Development Team

常常会碰到这样的情况: 一个子系统主要服务于另一个子系统; 下游组件执行分析或其他功能, 这些功能向上游组件反馈的信息非常少, 所有依赖都是单向的.

上游和下游子系统很自然地分隔到两个Bounded Context中, 只需要进行单向转换. 但两个团队的行政组织关系可能会引起问题.

如果下游团队对变更具有否决权, 或请求变更的程序太复杂, 那么上游团队的开发自由度会受到限制, 由于担心破坏下游系统, 上游团队甚至会受到抑制. 同时由于上游团队掌握优先权, 下游团队有时也会无能为力.

在两个团队之间建立一种明确的客户/供应商关系. 在计划会议中, 下游团队相当于上游团队的客户. 根据下游团队的需求来协商需要执行的任务并为这些任务做预算, 以便每个人都知道双方的约定和进度.

两个团队共同开发自动化验收测试, 用来验证预期的接口. 把这些测试添加到上游团队的测试套件中, 以便作为持续集成的一部分来进行. 这些测试使上游团队在做出修改时不必担心对下游团队产生副作用. 在迭代期间, 下游团队应该像传统的客户一样随时回答上游团队的提问, 并帮助解决问题.

Customer/Supplier Development Team模式有两个关键要素:

  1. 关系必须是客户与供应商的关系, 其中客户的需求是至关重要的.
  2. 必须有自动测试套件, 使上游团队在修改代码时不必担心破坏下游团队的工作, 并使下游团队能够专注于自己的工作, 而不用总是密切关注上游团队的行动.

模式: Conformist

当具有上游/下游关系的团队不归同一个管理者指挥时, Customer/Supplier Team的合作模式就不会奏效.

当两个开发团队具有上下游关系时, 如果上游团队没有动力来满足下游团队的需求, 那么下游团队将无能为力. 出于利他主义的考虑, 上游开发人员可能会做出承诺, 但他们可能不会履行承诺. 下游团队出于良好的意愿会相信这些承诺, 从而根据一些永远不会实现的特性来制定计划. 下游项目只能被搁置, 直到团队最终学会利用现有条件自力更生为止. 下游团队不会得到根据他们的需求而量身定做的接口.

在这种情况下, 有三种可能的解决路径:

  1. 完全放弃对上游的使用. 做出这种选择时, 应进行切实的评估, 绝不要假定上游会满足下游的需求, 即Separate Way各行其道的模式.
  2. 有时使用上游软件具有非常大的价值, 因此必须保持这种依赖性.如果上游的设计很难使用, 那么下游团队仍然需要开发自己的模型, 将担负起开发转换层的全部责任, 即Anticorruption Layer模式.
  3. 如果上游的设计质量不是很差, 而且风格能兼容的话, 那么最好不要再开发一个独立的模型, 这种情况下可以使用Conformist跟随者模式.

通过严格遵从上游团队的模型, 可以消除在Bounded Context之间进行转换的复杂性. 尽管这会限制下游设计人员的风格, 而且可能不会得到理想的应用程序模型, 但选择Conformist跟随者模式可以极大地简化集成. 此外还可以与供应商团队共享Ubiquitous Language. 供应商处于统治地位, 因此最好使沟通变容易. 他们从利他主义的角度出发, 会与你分享信息.

如果这些折中不可接受, 而上游的依赖又必不可少, 那么可以选择第二种方法, 通过创建一个Anticorruption Layer来尽可能把自己隔离开, 这是一种实现转换映射的积极方法.

Conformist模式类似于Shared Kernel模式, 两种模式中都有一个重叠的区域, 在这个重叠区域内模型是相同的, 此外还有你的模型所扩展的部分, 以及另一个模型对你没有影响的部分. 这两种模式的区别在于决策制定和开发过程不同. Shared Kernel是两个高度协调的团队之间的合作模式, 而Conformist则是应对一个对合作不感兴趣的团队进行集成.

模式: Anticorruption Layer

新系统几乎总是需要与遗留系统系统或其他系统进行集成, 这些系统具有自己的模型.

当正在构建的新系统与另一个系统的接口很大时, 为了克服连接两个模型而带来的困难, 新模型所表达的意图可能会被完全改变, 最终导致它被修改得像是另一个系统的模型了. 遗留系统的模型通常很弱, 即使开发得很好的, 它们可能不符合当前项目的需要. 然而集成遗留系统仍然具有很大的价值, 而且有时还是绝对必要的.

在大型项目中, 一个子系统通常必须与其他独立开发的子系统连接. 这些子系统从不同的角度反映问题领域, 当基于不同模型的系统被组合在一起时, 为了使新系统符合另一个系统的语义, 新系统自己的模型可能会被破坏.

当通过接口与外部系统连接时, 存在许多障碍, 如基础设施层必须提供与另一个系统进行通信的方法, 那个系统可能处于不同的平台上, 或使用不同的协议, 此时必须把那个系统的数据类型转换为自己系统的数据类型, 同时那个系统肯定不会使用相同的概念领域模型.

如果从一个系统中取出一些数据, 然后在另一个系统中错误地解释了它, 那么显然会发生错误, 甚至会破坏数据库. 数据与每个系统的关联方式会使数据的含义出现细微但重要的差别. 在原始数据的低层次上进行接口操作通常是错误的, 这样使得另一个系统的模型丧失了解释数据以及约束其值和关系的能力, 同时使新系统背负了解释原始数据的负担.

因此需要在不同模型的关联部分之间建立转换机制, 这样模型就不会被未经消化的外来模型元素所破坏.

创建一个隔离层, 以便根据客户自己的领域模型来为客户提供相关功能. 这个层通过另一个系统现有接口与其进行对话, 而只需对那个系统作出很少的修改, 甚至无需修改. 在内部, 这个层在两个模型之间进行必要的双向转换.

Anticorruption Layer并不是向另一个系统发送消息的机制, 相反它是在不同模型和协议之间转换概念对象和操作的机制.

设计Anticorruption Layer的接口

Anticorruption Layer的公共接口通常以一组Service的形式出现, 偶尔会采用Entity的形式.

构建一个全新的层来负责两个系统之间的语义转换, 使我们能够重新对另一个系统的行为进行抽象, 并按照我们的模型一致的方式把服务和信息提供给我们的系统.

实现Anticorruption Layer

对Anticorruption Layer设计进行组织的一种方法是把它实现为Facade, Adapter转换器的组合, 外加两个系统之间进行对话所需的通信和传输机制.

Facade是子系统的一个可供替换的接口, 简化了客户端访问, 并使子系统更易于使用. Facade并不改变底层系统的模型, 应该严格按照另一个系统的模型来编写. Facade应该属于另一个系统的Bounded Context.

Adapter是一个包装器, 运行客户使用另外一种协议, 这种协议可以是行为实现者不理解的协议. 当客户端向适配器发送一条消息时, Adapter把消息转换为一条在语义上等同的消息, 并将其发送给被适配者. 之后Adapter对响应消息进行转换, 并将其返回. 定义的每种Service都需要一个支持其接口的Adapter, 这个适配器还需要知道怎样才能向其他系统及其Facade发出相应的请求.

转换器承担概念对象或数据的实际转换的复杂任务, 可以是一个轻量级的对象, 可以在需要的时候被实例化, 只属于它所服务的Adapter, 不需要有状态, 不需要是分布式的.

Anticorruption Layer可以是双向的, 可能使用具有对称转换的相同转换器来定义两个接口上的Service并使用各自的Adapter. 尽管实现Anticorruption Layer通常不需要对另一个子系统做任何修改, 但为了使它能够调用Anticorruption Layer的Service, 有时还是有必要修改的.

通常需要一些通信机制来连接两个子系统, 而且它们可能位于不同的服务器上. 如果无法访问另一个子系统, 那么可能必须在Facade和另一个子系统之间设置通信连接. 如果Facade可以直接与另一个系统集成到一起, 那么可以在适配器和Facade之间设置通信连接. 有些情况下, 整个Anticorruption Layer可以与另一个子系统放在一起, 这时可以在你的系统和构成Anticorruption Layer接口的Service之间设置通信连接或分发机制.

如果有权访问另一个子系统, 对它进行少许的重构会使工作变得容易, 特别是应该为那些需要使用的功能编写更显式的接口, 首先从编写自动测试开始.

当需要进行广泛的集成时, 转换的成本会直线上升, 这时需要对正在设计的系统模型做出一些选择, 使之尽量接近外部系统, 以便使转换更加容易. 要非常小心不要破坏模型的完整性, 除非当转换的难度无法掌控时. 如果这种方法看起来是大部分重要问题的最自然的解决方案, 那么可以考虑子系统采用Conformist模式, 从而消除转换.

如果另一个子系统很简单或有一个很整洁的接口, 可能就不要Facade了.

如果一个功能是两个系统的关系所需的, 就可以把这个功能添加到Anticorruption Layer中. 比如外部系统使用情况的审计跟踪; 追踪逻辑, 用于调试对另一个接口的调用.

Anticorruption Layer是连接两个Bounded Context的一种方式. 不仅可以在两个子系统之间使用, 也可以用在自己开发的两个子系统的不同模型中.

隔离策略的益处必须平衡它产生的代价, 应该从实际出发, 对模型做出适度的修改, 使之能够更好地适应外部模型.

任何集成都是有开销的, 集成可能非常有价值, 但它的代价总是十分高昂的, 应该确保在真正需要的地方进行集成.

模式: Separate Way

必须严格划定需求的范围, 如果两组功能之间的关系并非必不可少, 那么二者完全可以彼此独立.

集成总是代价高昂, 而有时获益却很小. 集成迫使做出一些折中. 难以集成能提供某些特性的完全不同的却能轻松解决问题的技术. 或者某个团队难以合作.

在很多情况下集成并不会提供明显的收益. 如果两个功能部分并不需要互相调用对方的功能, 或者这两个部分所使用的对象并不需要进行交互, 或者在它们操作期间不共享数据, 那么集成可能是没有必要的.

声明一个与其他上下文毫无关联的Bounded Context, 使开发人员能够在这个小范围内找到简单的, 专用的解决方案.即Separate Way各行其道模式.

模式: Open Host Service

当一个子系统必须与大量其他系统进行集成时, 为每个集成都定制一个转换层可能会减慢团队的工作速度. 需要维护的东西会越来越多, 而且进行修改的时候担心的事情也会越来越多.

定义一个协议, 把你的子系统作为一组Service供其他系统访问. 开放这个协议, 以便所有需要与你的子系统集成的人都可以使用它. 当有新的集成需求时, 就增强并扩展这个协议, 但个别团队的特殊需求除外. 满足这种特殊需求的方法是使用一次性的转换器来扩充协议, 以便使共享协议简单且内聚. 即Open Host Service模式.

只有当子系统的资源可以被描述为一组内聚的Service并且必须进行很多集成的时候, 才值得这样做.

模式: Published Language

两个Bounded Context之间的模型转换需要一种公共的语言Published Language.

与现有领域模型进行直接的转换可能不是一种好的解决方案. 这些模型可能过于复杂或设计得较差. 它们可能没有被很好地文档化. 如果把其中一个模型作为数据交换语言, 它实质上就被固定住了, 无法满足新的开发需求.

Open Host Service使用一个标准化的协议来支持多方集成. 它使用一个领域模型来在各系统间进行交换, 尽管这些系统的内部可能并不使用该模型.

把一个良好文档化的, 能够表达出所需领域信息的共享语言(JSON, XML等)作为公共的通信媒介, 必要时在其他信息与该语言之间进行转换.

模型的统一

如果不需要集成, 那么模型统不统一就无关紧要. 如果需要进行一些集成, 需要接受各种不同的意见. 经过激烈的讨论后, 最终会认识到正在对一个更大整体的不同部分进行描述和建模.

模型集成的第一步只需要弄清楚各个部分是如何相连的. 不同模型纯粹是在描述整体的不同部分, 此时需要一个新的抽象, 这个抽象需要把一个模型的特性与另一个模型的功能合并到一起, 而且这个抽象还应该排除先前两个模型中的一些不确切的含义.

尽管已经把部分合并成一个整体, 但得到的模型还是简陋的, 缺乏内聚性, 没有形成任何潜在领域的轮廓. 在持续精化的过程中, 新的理解可能会产生更深层的模型. 新的应用程序需求也可能会促成更深层的模型.

模型集成的第二步是去掉各个模型中那些偶然或不正确的地方, 并创建新的概念. 成功的模型应该尽可能做到精简.

承认多个互相冲突的领域模型实际上正是面对现实的做法. 通过明确定义每个模型都适用的上下文, 可以维护每个模型的完整性, 并清楚地看到要在两个模型之间创建的任何特殊接口的含义.

选择你的模型上下文策略

在任何时候, 绘制出Context Map来反映当前状况是很重要的. 一旦绘制好Context Map后, 可能要改变现状, 可以有意识地选择Context的边界和关系. 下面是指导原则.

团队决策或更高层决策

首先, 团队必须决定在哪里定义Bounded Context以及它们之间的关系. 这些决策必须由团队做出, 或者至少传达给整个团队, 并且被团队里的每个人理解.

在决定是否扩展或分割Bounded Context时, 应该权衡团队独立工作的价值以及能产生直接切丰富集成的价值, 以这两种价值的成本/效益作为决策的依据. 在实践中, 团队之间的行政关系往往决定了系统的集成方式.

从一个现实的Context Map开始, 并根据实际情况来选择改变.

置身上下文中

开发软件项目时, 我们首先是对自己团队正在开发的那部分Context感兴趣, 其次是对那些与我们交互的系统Context感兴趣.

我们正是自己所处理的主要Context的一部分, 这会在我们的Context Map反映出来. 只要知道自己存在偏好, 并且在超出该Context Map的应用边界时能够意识到已越界, 那么就不会有什么问题.

转换边界

权衡划出Bounded Context的边界时所考虑的通常是下面的因素:

首选较大的Bounded Context时:

  1. 当用一个统一的模型来处理更多任务时, 用户任务之间的流动更顺畅.
  2. 一个内聚模型比两个不同模型再加它们之间的映射更容易理解.
  3. 两个模型之间的转换可能会很难, 甚至是不可能的.
  4. 共享语言可以使团队沟通起来更清楚.

首选较小的Bounded Context时:

  1. 开发人员之间的沟通开销减少了.
  2. 由于团队和代码规模较小, Continuous Integration更容易了.
  3. 较大的上下文要求更加通用的抽象模型, 而掌握所需技巧的人员会出现短缺.
  4. 不同的模型可以满足一些特殊需求, 或者是能够把一些特殊用户群的专门术语和Ubiquitous Language的专门术语包括进来.

在不同的Bounded Context之间进行深度功能集成是不切实际的. 在一个模型中, 只有那些能够严格按照另一个模型来表述的部分才能过进行集成, 即便是这种级别的集成可能也需要付出相当大的工作量.

接受那些我们无法更改的事物: 描述外部系统

一些无法立即淘汰的大型遗留系统和那些提供所需服务的外部系统显然不在开发中系统的任何Bounded Context中.

在做出假设时必须要保持谨慎, 我们会很轻易地认为这些系统构成了其自己的Bounded Context, 但大多数外部系统只是勉强满足定义. 明确地声明定义Bounded Context 的目的是把模型统一在特定边界之内.

与外部系统的关系

可以应用三种模式:

首先可以考虑Separate Way模式. 但一定要真正确定不需要集成.

如果集成确实非常重要, 可以在Conformist模式Anticorruption Layer模式中选择.

使用Conformist模式, 创造力和对新功能的选择都会受到限制.

  • 当对一个大的系统进行外围扩展时, 而且这个系统仍然是主要系统, 此时继续使用遗留模型就很合适.
  • 如果你的应用程序是现有系统的扩展, 而且与该系统的接口很大, 那么Context之间转换所需的工作量可能比应用程序功能本身需要的工作量还大, 此时可以考虑Conformist模式.
  • 如果另一个系统有着可以识别的领域模型, 那么只要使这个模型比在原来的系统中更清晰, 就可以改进实现, 唯一需要注意的是要严格地遵照老模型.
  • 如果决定采用Conformist设计, 就必须全心全意地去做, 应该约束自己只可以去扩展现有模型, 而不能去修改它.

当正在设计的系统功能并不仅仅是扩展现有系统, 而且你与另一个系统的接口很小, 或者另一个系统的设计非常糟糕, 那么实际上你会希望使用自己的Bounded Context, 这意味着需要构建一个转换层, 甚至一个Anticorruption Layer.

设计中的系统

你的项目团队正在构建的软件就是设计中的系统, 可以在这个区域内声明Bounded Context, 并在每个Bounded Context中应用Continuous Integration, 以便保持统一.

随着团队规模的增大, Continuous Integration可能会变得困难起来. 可能希望采用Shared Kernel模式, 并把几组相对独立的功能划分到不同的Bounded Context中, 使得在每个Bounded Context中工作的人员小于10人. 在这些Bounded Context中, 如果有两个上下文之间的所有依赖都是单向的, 就可以建成Customer/Supplier Development Team.

如果两个团的思想截然不同, 需要从模型得到完全不同的东西, 可以采用Separate Way模式. 在需要集成的地方, 两个团队可以共同开发并维护一个转换层, 把它作为唯一的Continuous Integration点. 这与同外部系统的集成相反, 在外部集成中, 一般由Anticorruption Layer起调节作用, 而且另一端得不到太多的支持.

一般来说, 咩哥Bounded Context对应一个团队. 一个团队也可以维护多个Bounded Context, 但多个团队在一个上下文中工作却是比较难的.

用不同模型满足特殊需要

同一业务的不同小组常常有各自的专用术语, 而且可能各不相同. 决定通过不同的Bounded Context满足这些需要. 除了转换层的Continuous Integration以外, 让模型采用Separate Way模式. Ubiquitous Language的不同专用术语将围绕这些模型以及它们所基于的行话来发展. 如果两种术语有很多重叠之处, 那么Shared Kernel模式可以满足特殊化需求, 同时又能把转换成本减至最小.

当不需要集成或者集成相对有限时, 可以继续使用已经习惯的术语, 以免破坏模型. 但也有代价和风险: 没有共同语言, 交流将减少; 集成开销更高; 随着相同业务活动和实体的不同模型的发展, 工作会有一定的重复.

但最大的风险或许是它会成为拒绝改变的理由, 或为古怪, 狭隘的模型辩护. 必须在团队独立操作的价值和转换的风险之间做出权衡, 并且留心合理地处理一些没有价值的术语变化.

有时会出现一个深层次的模型, 把不同语言统一起来, 并能够满足双方的要求. 只有经过大量开发工作和知识消化之后, 深层次模型才会在生命周期后期出现. 深层次模型不是计划出来的, 只能在它出现的时候抓住机遇, 修改自己的策略并进行重构.

在需要大量集成的地方, 转换成本会大大增加. 在团队之间进行一些协调工作(如从精确地修改一个具有复杂转换的对象到采用Shared Kernel模式)可以使转换变得更加容易, 同时又不需要玩的统一.

部署

Bounded Context策略的选择将影响部署. 例如, 当Customer/Supplier Team部署新版本时, 必须相互协调来发布共同测试的版本, 必须要进行代码和数据迁移. 在分布式系统中, 一种好的做法是把Context之间的所有转换层放在同一个进程中, 这样就不会出现多个版本共存的情况.

当数据迁移可能很花时间或者分布式系统无法同步更新时, 即使是单一Bounded Context中的部署也是很困难的, 这会导致代码和数据有两个版本共存.

由于部署环境和技术存在不同, 有很多技术因素需要考虑. 但Bounded Context关系可以指出重点问题, 转换接口已经被标出.

绘制Context边界时应该反映出部署计划的可行性. 当两个Context通过一个转换层连接时, 要想更新其中的一个Context, 新的转换层需要为另一个Context提供相同的接口. Shared Kernel需要进行更多的协调工作, 不仅在开发中如此, 而且在部署中也同样如此. Separate Way模式可以使工作简单得多.

权衡

一般来说, 需要在无缝功能集成的益处和额外的协调和沟通工作之间做出权衡.

需要在更独立的操作与更顺畅的沟通之间做出权衡.

更积极的统一需要对有关子系统的设计有更多的控制.

当项目正在进行时

很多情况下, 不是从头开发一个项目, 而是会改进一个正在开发的项目.

这种情况下: 第一步是根据当前的状况来定义Bounded Context. 为了有效地定义上下文, Context Map必须反映出团队的实际工作.

描述了当前真实的Bounded Context以及它们的关系后, 下一步就是围绕当前组织结构来加强团队的工作. 在Context中加强Continuous Integration. 把所有分散的转换代码重构到Anticorruption Layer中. 命名现有的Bounded Context, 并确保它们处于项目的Ubiquitous Language中.

转换

在很多情况下, 必须改变最初有关边界以及Bounded Context之间关系的决策, 分割Context很容易, 但合并它们或改变它们之间的关系却很难. 下面将介绍几种有代表性的修改.

合并Context: Separate Way -> Shared Kernel

合并Bounded Context的动机很多, 翻译开销过高, 重复现象很明显. 合并很难, 但什么时候做都不晚, 只是需要一点耐心.

即使最终目标是完全合并成一个采用Continuous Integration的Context, 也应该先过渡到Shared Kernel.

  1. 评估初始情况. 在开始统一两个Context之前一定要确信它们确实需要统一.

  2. 建立合并过程. 需要决定代码的共享方式以及模块应该采用哪种命名约定. Shared Kernel的代码至少每周要集成一次, 而且它必须有一个测试套件, 在开发任何共享代码之前, 必须先设置好测试套件.

  3. 选择某个小的领域作为开始, 它应该是两个Context中重复出现的子领域, 但不是Core Domain的一部分. 最终的合并是为了建立合并过程, 因此最好选择一些简单且相对通用或不重要的部分.

    有三种合并方法:

    选择一个模型, 并重构另一个Context, 使之与第一个模型兼容. 1)可以从整体上把目标设置为系统性地替换一个Context的模型, 并保持开发模型的内聚性. 2)可以一次选择一部分进行合并.

    3)找到一个新模型, 这个模型可能比最初的两个都深刻, 能够承担二者的职责.

  4. 从两个团队中共选出2到4位开发人员组成一个小组, 由他们来为子领域开发一个共享的模型, 模型内容必须详细. 工作内容包括: 识别同义词和映射那些尚未翻译的术语, 为模型开发一个基本的测试套件.

  5. 来自两个团队的开发人员一起负责实现模型或修改要共享的现有代码, 确定各种细节并使模型开始工作. 如果开发人员在模型中遇到了问题, 从第3步开始重新组织团队, 并进行必要的概念修订工作.

  6. 每个团队的开发人员都承担与新的Shared Kernel集成的任务.

  7. 清除那些不再需要的翻译.

这时会得到一个非常小的Shared Kernel, 并且有一个过程来维护它. 在后续的项目迭代中, 重复3到7步来共享更多内容.

如果两个模型对领域有关的部分各自采用了不同用户群的专用术语时, 那么先不要把它们合并到Shared Kernel中, 除非工作中出现了突破, 得到了一个深层模型, 这个模型提供了一种能够替代那两种专用术语的语言.

如果两个模型中有一个是符合首选的, 那么就考虑向它过渡, 而不用集成, 不用共享公共的子领域, 只是系统性地通过重构应用程序把这些子领域的所有职责从一个Bounded Context转移到另一个Bounded Context, 从而使用那个更受青睐的Context的模型, 并对该模型进行需要的增强, 这样就实现了与合并完全一样的效果.

合并Context: Shared Kernel -> Continuous Integration

如果Shared Kernel正在扩大, 两个Bounded Context可能会完全统一. 这不仅解决模型差异, 而且将改变团队结构, 最终会改变所使用的语言.

  1. 确保每个团队都已经建立了Continuous Integration所需的所有过程(如共享代码, 频繁集成等). 两个团队协商集成步骤, 以便所有人都以同一步调工作.

  2. 团队成员在团队之间流动, 可以形成一大批同时理解两个模型的人员, 并且可以把两个团队的人员联系起来.

  3. 澄清每个模型的精髓.

  4. 团队有足够的信心把核心领域合并到Shared Kernel. 这可能需要多次迭代, 有时需要在新共享的部分和尚未共享的部分之间使用临时的转换层. 一旦进入到合并Core Domain的过程中, 最好能快速完成. 这是一个开销高且易出错的阶段, 应该尽可能缩短时间, 要优于新的开发任务, 但注意量力而行, 不要超过你的处理能力.

    合并Core模型的方式:

    1)可以保持一个模型, 然后修改另一个, 使之与第一个兼容.

    2)可以为子领域模型创建一个新模型, 并通过修改两个上下文来使用这个模型.

    3)如果两个模型已经被修改以满足不同用户的需要, 需要保留两个厨师模型中的专业能力, 这就要求开发一个能够替代两个元素模型的更深层模型.

  5. 随着Shared Kernel的增长, 把集成频率提高到每天一次, 最后实现Continuous Integration.

  6. 当Shared Kernel逐渐把先前两个Bounded Context的所有内容都包括进来的时候, 会发现要么形成了一个大的团队; 要么形成了两个较小的团队, 两个较小的团队共享一个Continuous Integration的代码库, 而且团队成员可以经常在两个团队之间来回流动.

逐步淘汰遗留系统

这里讨论一种常见的情况: 用一系列更现代的系统来补充业务中每天都在使用的系统, 新系统通过一个Anticorruption Layer与老系统进行通信.

首先要执行的步骤是确定测试策略. 应该为新系统中的新功能编写自动的单元测试, 但逐步淘汰遗留系统还有一些特殊的测试需求. 一些组织在某段时间内会同时运行新旧两个系统.

在任何一次迭代中:

  1. 确定遗留系统的哪个功能可以在一个迭代中被添加到某个新系统中.

  2. 确定需要在Anticorruption Layer中添加的功能.

  3. 实现

  4. 部署

    有时需要进行多次迭代才能编写一个与遗留系统的某个功能等价的功能单元. 这时在计划新的替代功能时仍以小规模的迭代为单元, 最后一次性部署多次迭代.

    如果小规模, 增量的改动能够推到生产环境, 那是真的再好不过了, 但通常情况下, 还是需要将它们组织成更大的发布.

    一旦最终进入运行阶段, 应该遵循如下步骤:

  5. 找出Anticorruption Layer中那些不必要的部分, 并去掉它们.

  6. 考虑删除遗留系统中目前未使用的模块, 虽然做法未必实际. 可以暂时忽略未使用的部分, 直到将来剩余部分已经被淘汰, 这时整个遗留系统就可以停止使用了.

不断重复这几个步骤, 遗留系统应该越来越少地参与业务, 最终替换工作会看到希望的曙光并完全停止遗留系统. 同时随着各种组合增加或减小系统之间的依赖, Anticorruption Layer将相应地收缩或扩张. 在其他条件都相同的情况下, 应该首先迁移那些只产生较小Anticorruption Layer的功能.

Open Host Service -> Published Language

已经通过一系列特定的协议与其他系统进行了集成, 但随着需要访问的系统逐渐增多, 维护负担也不断增加, 或者交互变得很难理解. 此时需要通过Published Language来规范系统之间的关系.

  1. 如果有一种行业标准语言可用, 则尽可能评估并使用它.
  2. 如果没有标准语言或预先公开发布的语言, 则完善作为Host的系统的Core Domain.
  3. 使用Core Domain作为交换语言的基础, 尽可能使用像XML, JSON这样的交互标准范式.
  4. 至少向所有参与写作的各方向发布新语言.
  5. 如果涉及新的系统架构, 那么也要发布它.
  6. 为每个协作系统构建转换层.
  7. 切换.

现在, 当加入更多协作系统时, 对整个系统的破坏已经减至最小了.

Published Language必须是稳定的, 但是当继续进行重构时, 仍然需要能够自由地更改Host的模型. 不要把交互语言和Host的模型等同起来. 保持它们的密切关系可以减小转换开销, 而Host可以采用Conformist模式.

项目领导者应该根据功能集成需求和开发团队之间的关系来定义Bounded Context. 一旦Bounded Context和Context Map被明确地定义下来并获得认可, 就应该保持它们的逻辑一致性. 最起码要把相关的通信问题提出来, 以便解决它们.

精炼

精炼是把一堆混杂在一起的组件分开的过程, 以便通过某种形式从中提取出最重要的内容, 而这种形式将使它更有价值更有用. 模型就是知识的精炼. 通过每次重构所得到的更深层的理解, 得以把关键的领域知识和优先级提取出来.

精炼的主要价值是把最有价值的部分提取出来, 即Core Domain.

领域模型的战略精炼包括以下部分:

  1. 帮助所有团队成员掌握系统的总体设计以及各部分如何协调工作.
  2. 找到一个具有适度规模的核心模式并把它添加到通用语言中, 从而促进沟通.
  3. 指导重构.
  4. 专注于模型中最有价值的部分.
  5. 指导外包, 现成组件的使用以及任务委派.

模式: Core Domain

在设计大型系统时, 有非常多的组成部分, 它们都很复杂而且对开发的成功也至关重要, 但导致真正的业务资产领域模型最为精化的部分被掩盖和忽略了.

一个严峻的现实是我们不可能对所有设计部分进行同等的精化, 而是必须分出优先级. 为了使领域模型成为有价值的资产, 必须整齐地梳理出模型的真正核心, 并完全根据这个核心来创建应用程序的功能. 但本来就稀缺的高水平开发人员往往会把工作重点放在技术基础设施上, 或者只是去解决那些不需要专门领域知识就能理解的领域问题(这些问题都已经有了很好的定义).

如果软件的核心部分实现得很差, 那么无论技术基础设施有多好, 无论支持功能有多完善, 应用程序永远都不会为用户提供真正有吸引力的功能. 这个严重问题的根源在于项目没有一个明确的整体设计视图, 而且也没有认清各个部分的相对重要性.

在制定项目规划的时候, 必须把资源分配给模型和设计中最关键的部分, 在规划和开发期间每个人都必须识别和理解这些关键部分. 这些部分是应用程序的标志性部分, 也是目标应用程序的核心诉求, 它们构成了Core Domain. Core Domain是系统上最有价值的部分.

对模型进行提炼. 找到Core Domain并提供一种易于区分的方法把它与那些起辅助作用的模型和代码分开. 最有价值和最专业的概念要轮廓分明. 尽量压缩Core Domain.

最有才能的人来开发Core Domain, 并据此要求进行相应的招聘. 在Core Domain中努力开发能够确保实现系统蓝图的深层模型和柔性设计. 仔细判断任何其他部分的投入, 看它是否能够支持这个提炼出来的Core.

选择核心

需要关注的是那些能够表示业务领域并解决业务问题的模型部分.

Core Domain的选择取决于看问题的角度. 一个应用程序的Core Domain在另一个应用程序中可能只是通用的支持组件. 尽管如此, 仍然可以在一个项目中定义一个一致的Core. 像其他的设计部分一样, 对Core Domain的认识也会随着迭代而发展. 开始时, 一些特定关系可能显得不重要. 而最初被认为是核心的对象可能逐渐被证明只是起支持作用.

工作的分配

在项目团队中, 技术能力最强的人员往往缺乏丰富的领域知识, 这限制了他们的作用, 并且更倾向于分派他们来开发一些支持组件, 从而形成了一个恶性循环: 知识的缺乏使他们远离了能够学到领域知识的工作.

打破这种恶性循环的方法是建立一支由开发人员和一位或多位领域专家组成的联合团队, 其中开发人员必须能力很强, 能够长期稳定地工作并且对学习领域知识非常感兴趣, 而领域专家则要掌握深厚的业务知识.

从外界聘请一些短期的专业人员来设计Core Domain的关键环节通常是行不通的, 因为团队需要累积领域知识, 而且短期人员会造成知识流失. 相反, 充当培训和指导角色的专家可能非常有价值, 因为他们帮助团队建立领域设计技巧, 并促使团队成员使用尚未掌握的高级设计原则. 同样的原因, 购买Cre Domain也是行不通的.

自主开发的软件的最大价值来自于对Core Domain的完全控制. 一个设计良好的框架可能会提供满足专门使用需求的高水平抽象, 它可以节省开发那些更通用部分的时间, 并使你能够专注于Core. 但是如果它对你的约束超出了限度, 可能有三种原因:

  1. 你正在失去一项重要的软件资产. 此时应该让这些限制性的框架退出你的Core Domain.
  2. 框架所处理的部分并不是你所认为的核心. 此时应该重新划定Core Domain的边界, 把你的模型中真正的标志性部分识别出来.
  3. 你的Core Domain并没有特殊的需求. 此时应该考虑一种风险更低的解决方案, 如购买软件并与你的应用程序进行集成.

不管哪种情况, 创建与众不同的软件需要一支稳定工作的团队, 他们不断积累和消化专业知识, 并将这些知识转化为一个丰富的模型.

精炼的逐步提升

下面要介绍各种精炼技术:

  1. 一份简单的Domain Vision Statement领域愿景说明只需很少的投入, 并传达了基本概念以及它们的价值. Highlighted Core突出核心可以增进沟通, 并指导决策制定, 这只需对设计进行很少的改动甚至无需改动.
  2. 更积极的精炼方法是通过重构和重新打包显式地分离出Generic Subdomain通用子领域, 然后单独进行处理. 在使用Cohesive Mechanism的同时也要保持设计的通用性, 易懂性和柔性. 这两个方面可以结合起来. 只有去除了细枝末叶, 才能把Core剥离出来.
  3. 重新打包出一个Segregated Core分离的核心, 可以使这个Core清晰可见, 并且促进将来在Core模型上的工作.
  4. 最富雄心的精炼是Abstract Core抽象核心, 它用纯粹的形式表示了最基本的概念和关系, 因此需要对模型进行全面的重新组织和重构.

每种技术都需要连续不断地投入越来越多的工作, 但刀磨越薄, 就会越锋利. 领域模型的连续精炼将为我们创造一项资产, 使项目进行得更快, 更敏捷, 更精确.

模式: Generic Subdomain

模型中有些部分除了增加复杂性以外并没有捕捉或传递任何专门的知识. 任何外来因素都会使Core Domain愈发的难以分辨和理解. 模型中充斥着大量众所周知的一般原则, 或者是专门的细节, 这些细节并不是主要的关注点, 而只是起到支持作用. 然而无论它们是多么通用的元素, 它们对实现系统功能和充分表达模型都是极为重要的.

识别出那些与项目意图无关的内聚子领域. 把这些子领域的通用模型提取出来, 并放到单独的Module中. 任何专有的东西都不应放在这些模块中.

把它们分离出来以后, 在继续开发的过程中, 它们的优先级应低于Core Domain的优先级, 并且不要分派核心开发人员来完成这些任务, 因为他们很少能够从这些任务中获得领域知识. 此外还可以考虑为这些Generic Subdomain使用现成的解决方案或公开发布的模型Published Model.

当开发这样的软件包时, 有以下四种选择.

选择1: 现成的解决方案

有时可以购买一个已实现好的解决方案, 或使用开源代码.

优点缺点
可以减少代码的开发使用之前, 需要花时间来评估和理解它
维护负担转移到了外部就业内目前的质量控制水平而言, 无法保证它的正确性和稳定性
代码已经在很多地方使用过, 较为成熟, 比自己开发的代码更可靠和完备设计得过于细致, 超出你的目的, 集成的工作量比开发一个最小化的内部实现更大
外部元素的集成常常不顺利, 可能有一个与你的项目完全不同的Bounded Context
很难顺利地利用你的其他软件包中的Entity
可能会引入平台, 编译版本的依赖等

选择2: 公开发布的设计或模型

优点缺点
比自己开发的模型更为成熟, 并且反映了很多人的深层知识可能不是很符合你的需要, 或者设计得过于细致了
提供了随时可用的高质量文档

如果领域中已经有了一种非常正式且严格的模型, 那么就使用它.

如果在一个公开发布的模式中能够发现一个简化的子集, 它本身是一致的且能够满足你的要求, 那么就不要强迫自己完全实现一个这样的模型.

如果一个模型已经有人很好地研究过了, 并且提供了完备的文档, 甚至已经得到正规化, 那么重新去设计它就没有意义了.

抄袭! 抄袭! 在领域建模中, 特别是在攻克Generic Subdomain时, 是金玉良言.

选择3: 把实现外包出去

优点缺点
使核心团队可以脱身去处理Core Domain, 那才是最需要知识和经验积累的部分仍需要核心团队花费一些时间, 需要与外包人员商量接口, 编码标准和其他重要方面
开发工作的增加不会使团队规模无限扩大下去, 同时又不会导致Core Domain知识的分散当把代码移交回团队时, 团队需要耗费大量精力来理解这些代码
强制团队采用面向接口的设计, 并且有助于保持子领域的通用性, 因为规格已经被传递到外部代码质量或高或低, 取决于两个团队能力的高低

自动测试在外包中可能起到重要作用, 应该要求外包人员为他们交付的代码提供单元测试. 真正有用的方法是为外包的组件详细说明甚至是编写自动验收测试, 这有助于确保质量, 明确规格并且使这些组件的再集成变得顺利.

选择4: 内部实现

优点缺点
易于集成需要承受后续的维护和培训负担
只开发自己需要的, 不做多余的工作很容易低估开发这些软件所需的时间和成本
可以临时把工作分包出去

Generic Subdomain是充分利用外部设计专家的地方, 因为这些专家不需要深入理解特有的Core Domain, 而且他们没有太大的机会学习这个领域. 这些模块几乎不涉及专有信息或业务实际, 几乎没有机密性问题. Generic Subdomain可以减轻对不了解领域知识人员进行培训而带来的负担.

通用不等于可重用

子领域的通用性并不等同于代码的可重用性. 如果要自己实现代码, 那么不要特别关注代码的可重用性, 这样做会违反精炼的基本动机.

重用确实会发生, 但不一定总是代码重用. 模型重用是更高级的重用. 自己创建子领域的模型时, 不必开发成万能的模型, 只要把业务所需的那部分建模出来并实现即可.

通用子领域的设计必须严格地限定在通用概念的范围之内. 如果把行业专用的模型元素引入到通用子领域, 会产生两个后果:

  1. 它会妨碍将来的开发, 把任何不属于子领域概念的部分引入到设计中, 很难再灵活地扩展系统了.
  2. 行业专用的概念要么属于Core Domain, 要么属于它们自己更专业的子领域, 而且这些专业的模型比通用子领域更有价值.
项目风险管理

项目面临着两方面的风险: 技术风险, 领域建模的风险.

除非团队拥有精湛的技术并对领域非常熟悉, 否则第一个雏形系统应该以Core Domain的某个部分作为基础, 不管它有多么简单.

相同的原则也适用于任何试图把高风险的任务放到前面处理的过程. Core Domain就是高风险的, 因为它的难度往往会超出预料, 而且如果没有它, 项目就不可能成功.

模式: Domain Vision Statement

项目开始时, 模型通常不存在, 但是模型开发的需求是早就确定下来的重点. 在后面的开发阶段, 需要解释清楚系统的价值, 但这并不需要深入地分析模型. 此外领域模型的关键方面可能跨越多个Bounded Context, 而且从定义上看, 无法将这些彼此不同的模型组织起来表明其共同的关注点.

Domain Vision Statement领域愿景说明关注的重点是领域模型的本质, 以及如何为企业带来价值. 在项目开发的所有阶段, 管理层和技术人员都可以直接用领域愿景说明来指导资源分配, 建模选择和团队成员的培训.

写一份Core Domain的简短描述(大约一页纸)以及它将会创造的价值, 也就是价值主张. 那些不能将你的领域模型与其他领域模型区分开的方面就不要写了. 展示出领域模型是如何实现和均衡各方利益的. 这份描述要尽量精简. 尽早把它写出来, 随着新的理解随时修改它.

Domain Vision Statement可以用作一个指南, 帮助开发团队在精炼模型和代码的过程中保持统一的方向. 团队的非技术人员, 管理层甚至是客户都可以共享领域愿景说明.

模式: Highlighted Core

尽管团队成员可能大体上知道核心领域是由什么构成的, 但Core Domain中到底包含哪些元素, 不同的人会有不同的理解, 甚至同一个人在不同的时间也会有不同的理解. 如果总是要不断过滤模型以便识别出关键部分, 那么就会分散本应该投入到设计上的精力, 而且这还需要广泛的模型知识. 因此Core Domain必须要很容易被分辨出来.

对代码所做的重大结构性改动是识别Core Domain的理想方式, 但这些改动往往无法在短期内完成. 事实上, 如果团队的认识还不够全面, 这样的重大代码修改是很难进行的. 需要一种轻量级的解决方案.

精炼文档

创建一个单独的文档来描述和解释Core Domain. 这个文档可能很简单, 只是最核心的概念对象的清单. 它可能是一组描述这些对象的图, 显示了它们最重要的关系. 它可能在抽象层次上或通过示例来描述基本的交互过程. 它可能会使用UML类图或序列图, 专用于领域的非标准图, 措辞严谨的文字解释或上述这些元素的组合.

精炼文档并不是完备的设计文档, 只是一个最简单的切入点, 描述并解释了核心, 并给出了更进一步研究这些核心部分的理由. 精炼文档为读者提供了一个总体视图, 指出了各个部分是如何组合到一起的, 并且指导读者到相应的代码部分寻找更多细节.

精炼文档作为Highlighted Core突出核心的一种形式:

编写一个非常简短的文档(3到7页, 每页内容不必太多), 用于描述Core Domain以及Core元素之间的主要交互过程.

独立文档带来的所有常见风险有:

  1. 文档可能得不到维护
  2. 文档可能没人阅读
  3. 由于有多个信息来源, 文档可能达不到简化复杂性的目的

控制这些风险的最好方法是保持绝对的精简, 剔除那些不重要的细节, 只关注核心抽象以及它们的交互, 这样文档的老化速度就会减慢, 因为这个层次的模型通常更稳定.

精炼文档应该能够被团队中的非技术人员理解, 把它当作一个共享的视图, 描述每个人都应该知道的东西, 而且可以把它作为团队所有成员研究模型和代码的一个起点.

标明Core

标明Core作为Highlighted Core突出核心的另一种形式:

把模型的主要存储库中的Core Domain标记出来, 不用特意去阐明其角色. 使开发人员很容易就知道什么在核心内, 什么在核心外.

把精炼文档作为过程工具

如果精炼文档概括了Core Domain的核心元素, 那么它就可以作为一个指示器, 用以指示模型改变的重要程度.

当模型或代码的修改影响到精炼文档时, 需要与团队其他成员一起协商. 当对精炼文档做出修改时, 需要立即通知所有团队成员, 而且要把新版本的文档分发给他们.

Core外部的修改或精炼文档外部的细节修改无需协商或通知, 可以直接把它们集成到系统中, 其他成员在后续工作过程中自然会看到这些修改. 这样开发人员就拥有了完全的自治性.

模式: Cohesive Mechanism

封装机制是面向对象设计的一个基本原则, 把复杂算法隐藏到方法中, 再为方法起一个一看就知道其用途的名字, 这样就把做什么和如何做分开了.

计算有时会非常复杂, 使设计开始变得膨胀. 机械性的如何做大量增加, 把概念性的做什么完全掩盖了. 为解决问题提供算法的大量方法掩盖了那些用于表达问题的方法.

这种方法的扩散是模型出现问题的一种症状. 这时应该通过重构得到更深层的理解, 从而找到更适合解决问题的模型和设计元素. 首先要寻找的解决方案是找到一个能使计算机制变得简单的模型.

把概念上的Cohesive Mechanism内聚机制分离到一个单独的轻量级框架中, 要特别注意公式或那些有完备文档的算法. 用一个Intention-Revealing Interface来暴露这个框架的功能. 现在领域中的其他元素就可以只专注于如何表达问题(做什么)了, 而把解决方案的复杂细节(如何做)转移给了框架.

这些被分离出来的机制承担起支持的任务, 从而留下一个更小的, 表达得更清楚的Core Domain, 这个核心以根据声明式的方式通过接口来使用这些机制. 避免在算法中混杂用于表达问题的领域模型, 两者的职责应该分离.

Core Domain或Generic Domain的模型描述的是事实, 规则或问题, 而Cohesive Mechanism则用来满足规则或者用来完成模指定的计算.

Generic Subdomain与Cohesive Mechanism的比较

Generic Subdomain与Cohesive Mechanism的动机是相同的, 都是为Core Domain减负, 区别在于二者所承担的职责的性质不同.

Generic Subdomain是以描述性的模型作为基础的, 它用这个模型来表示出团队会如何看待领域的某个方面.

Cohesive Mechanism并不表示领域, 它的目的是解决描述性模型所提出来的一些复杂的计算问题. 模型提出问题, Cohesive Mechanism解决问题.

如果发现一些先前未识别的模型概念会使算法机制变得更为简单, 那么可以把这种算法精炼成一种更纯粹的机制, 或者转换为一个Generic Subdomain.

Mechanism是Core Domain的一部分

如果Mechanism本身就是专有的, 并且是软件的一项核心价值, 那么不能把Mechanism从Core Domain中分离出去, 该机制是概念核心的一部分.

通过精炼得到声明式风格

精炼的价值在于使你能够看到自己正在做什么, 不让无关细节分散你的注意力, 并通过不断削减得到核心.

如果领域中那些起到支持作用的部分提供了一种简练的语言, 可用于表示Core的概念和规则, 同时又能够把计算或实施这些概念和规则的方式封装起来, 那么Core Domain的重要部分就可以采用声明式设计.

Cohesive Mechanism用途最大的地方是它通过一个Intention-Revealing Interface来提供访问, 并且具有概念上一致的Assertion和Side-Effect-Free Function. 利用这些Mechanism和柔性设计, Core Domain可以使用有意义的声明, 而不必调用难懂的函数.

深层模型往往与相对应的柔性设计一起产生. 柔性设计变得成熟的时候, 就可以提供一组易于理解的元素. 可以明确地把它们组合到一起来完成复杂的任务或表达复杂的信息, 就像单词组成句子一样. 此时客户端代码可以采用声明式风格, 而且更为精炼.

模式: Segregated Core

模型中的元素可能有一部分属于Core Domain, 而另一部分起支持作用. 核心元素可能与一般元素紧密耦合在一起. Core的概念内聚性可能不是很强, 看上去也不明显. 这种混乱性和耦合关系抑制了Core. 设计人员如果无法清晰地看到最重要的关系, 就会开发出脆弱的设计.

对模型进行重构, 把核心概念从支持性元素(包括定义不清楚的那些元素)中分离出来, 并增强Core的内聚性, 同时减少它与其他代码的耦合. 把所有通用元素或支持性元素提取到其他对象中, 并把这些对象放到其他的包中, 即使这会把一些紧密耦合的元素分开.

通过重构得到Segregated Core分离的核心的一般步骤如下:

  1. 识别出一个Core子领域. 可能是从精炼文档中得到的.
  2. 把相关的类移到新的Module中, 并根据这些类有关的概念为模块命名.
  3. 对代码进行重构, 把那些不直接表示概念的数据和功能分离出来, 把分离出来的元素放到其他包的类或新类中.
  4. 对新的Segregated Core Module进行重构, 使其中的关系和交互变得更简单, 表达得更清楚, 并且最大限度地减少并澄清它与其他Module的关系.
  5. 对另一个Core子领域重复这个过程, 直到完成Segregated Core的工作.
创建Segregated Core的代价

有时把Core分离出来会使得它与那些紧密耦合的非Core类的关系变得更晦涩甚至更复杂, 但Core Domain更清晰了, 而且易于理解, 因此获得的好处还是足以抵偿这种代价.

Segregated Core能够提高Core Domain的内聚性.

另一个代价是分离Core需要付出很大的工作量, 在做出Segregated Core的决定时, 有可能需要开发人员对整个系统做出修改.

当系统有一个很大的, 非常重要的Bounded Context时, 但模型的关键部分被大量支持性功能掩盖了, 那么就需要创建Segregated Core了.

不断发展演变的团队决策

像很多战略决策所要求的一样, 创建Segregated Core需要整个团队一致行动. 这一行动需要团队的一致决策, 而且团队必须能够自律和协调才能执行这样的决策.

由于Core Domain是不断演变的, 对其新的理解必须持续不断地在整个团队中共享, 但个人不能单方面根据这些理解擅自采取行动. 决策过程必须具有足够的敏捷性, 可以反复纠正. 团队必须进行有效的沟通, 以便每个人都共享同一个Core视图.

模式: Abstract Core

当不同Module的子领域之间有大量交互时, 要么需要在Module之间创建很多应用, 这在很大程度上抵消了划分模块的价值; 要么就必须间接地实现这些交互, 而这会使模型变得晦涩难懂.

如果Module之间的大部分交互都可以在多态接口这个层次上表达出来, 那么就可以把这些类型重构到一个特定的Core Module中. 只有当领域中的基本概念能够用多态接口来表达时, 这才是一种有价值的技术.

把模型中最基本的概念识别出来, 并分离到不同的类, 抽象类或接口中. 设计这个抽象模型, 使之能够表达出重要组件之间的大部分交互. 把这个完整的抽象模型放到它自己的Module中, 而专用的, 详细的实现类则留在由子领域定义的Module中.

现在大部分专用的类都将引用Abstract Core Module, 而不是其他专用的Module. Abstract Core抽象核心提供了主要概念及其交互的简化视图.

对Abstract Core进行建模需要深入理解关键概念以及它们在系统中的主要交互中扮演的角色. Abstract Core是通过重构得到更深层理解的, 而且它通常需要大量的重新设计.

如果项目中同时使用了Abstract Core和精炼文档, 而且精炼文档随着应用程序理解的加深而不断演变, 那么抽象核心的最后结果看起来应该与精炼文档非常类似. Abstract Code是用代码编写的, 更为严格和完整.

深层模型精炼

精炼不仅限于从整体上把领域中的一部分从Core中分离出来, 也意味着对子领域进行精炼, 通过持续重构得到更深层的理解, 从而向深层模型和柔性设计推荐. 深层模型把领域中最本质的方面精炼成一些简单的元素, 可以把这些元素组合起来解决应用程序中的重要问题.

尽管任何带来深层模型的突破都有价值, 但只有Core Domain中的突破才能改变整个项目的轨道.

选择重构目标

当遇到一个杂乱无章的大型系统时, 有下面两种重构策略:

  1. 如果采用哪儿痛治哪儿重构策略, 要观察一下根源问题是否涉及Core Domain或Core与支持元素的关系. 如果确实涉及, 那么就要接受挑战, 首先修复核心.
  2. 如果采用自由选择的重构策略, 应首先集中精力把Core Domain更好地提取出来, 完善对Core的分离, 并且把支持性的子领域提炼成通用子领域.

大型结构

在一个大的系统中, 如果因为缺少一种全局性的原则而使人们无法根据元素在模式中的角色来解释这些元素, 那么开发人员会陷入只见树木不见树林的境地.

大型结构是一种语言, 可以用它来从大局上讨论和理解系统. 它用一组高级概念或规则来为整个系统的设计建立一种模式. 这种组织原则既能指导设计, 又能帮助理解设计. 它还能够协调不同人员的工作, 因为它提供了共享的整体视图, 让人们知道各个部分在整体中的角色.

设计一种应用于整个系统的规则或角色和关系的模型, 使人们可以通过它在一定程度上了解各个部分在整体中所处的位置.

大型结构是用来勾画和解释模型及设计的, 但在设计中并不出现, 只是用来表达设计的一种方式.

模式: Evolving Order

一个没有任何规则的随意设计会产生一些无法理解整体含义且很难维护的系统. 但架构中早期的设计假设又会使项目变得束手束脚, 而且会极大地限制应用程序中某些特定部分的开发人员/设计人员的能力. 很快, 开发人员就会为适应架构而不得不在应用程序的开发上委曲求全, 要么就是完全推翻架构而又回到没有协调的开发老路上来.

让这种概念上的大型结构随着应用程序一起演变, 甚至可以变成一种完全不同的结构风格. 不要依此过分限制详细的设计和模型决策, 这些决策和模型决策必须在掌握了详细知识之后才能确定.

选择大型结构时应该侧重于整体模型的管理, 而不是优化个别部分的结构. 在结构统一和用最自然的方式表示个别组件之间需要做出一些折中选择. 根据实际经验和领域知识来选择结构, 并避免采用限制过多的结构, 如此可以降低折中的难度.

大型结构能够为设计决策提供捷径, 可以帮助重构变得易于管理, 并使不同的人得到一致的解决方案.

大型结构通常需要跨越Bounded Context来使用, 应该为在不同Context中工作的开发团队保留一定的自由, 允许他们为了局部需要而修改模型.

大型结构必须适应开发工作中的实际约束.

与Context Map不同的是大型结构是可选的. 当使用某种结构可以节省成本并带来益处时, 就应该使用它. 如果一个系统简单到把它分解为Module就足以理解它, 那么就不必使用大型结构了. 当发现一种大型结构可以明显使系统变得更清晰, 而又没有对模型开发施加一些不自然的约束时, 就应该使用它. 使用不合适的结构还不如不使用, 最好不要为了追求设计的完整性而勉强去使用一种结构, 而应该找到尽可能精简的方式解决所出现的问题. 要记住宁缺毋滥的原则.

大型结构可能非常有帮助, 但有少数不适用的情况, 这些例外情况应该以某种方式被标记出来, 以便让开发人员知道在没有特殊注明时可以遵循这种结构. 如果不适用的情况大量出现, 就要修改这种结构, 或者干脆不用它.

模式: System Metaphor

软件设计往往非常抽象且难于掌握. 开发人员和用户都需要一些切实可行的方式来理解系统, 并共享系统的一个整体视图.

System Metaphor系统隐喻是一种松散的, 易于理解的大型结构, 与对象范式是协调的. 由于系统隐喻只是对领域的一种类别, 因此不同模型可以用近似的方式与它关联, 这使得能够在多个Bounded Context中使用系统隐喻, 从而有助于协调各个Bounded Context之间的工作.

系统的一个具体类比正好符合团队成员对系统的想象, 并且能够引导他们向着一个有用的方向进行思考时, 就应该把这个类别用作一种大型结构. 围绕这个隐喻来组织设计, 并把它吸收到Ubiquitous Language中. System Metaphor应该既能促进系统的交流, 又能指导系统的开发. 它可以增加系统不同部分之间的一致性, 甚至可以跨越不同的Bounded Context. 但所有的隐喻都不是完全精确的, 因此应不断检查隐喻是否过度或不恰当, 当发现它起到妨碍作用时, 要随时准备放弃它.

System Metaphor并不适用所有项目. 从总体上讲, 大型结构并不是必须要用的. 当项目中发现一种非常合适的System Metaphor或其他大型结构时, 应该用它来补充Ubiquitous Language.

模式: Responsibility Layer

如果每个对象的职责都是人为分配的, 将没有统一的指导原则和一致性, 也无法把领域作为一个整体来处理. 为了保持大模型的一致, 有必要在职责分配上实施一定的结构化控制.

注意观察模型中的概念依赖性, 以及领域中不同部分的变化频率和变化的原因. 如果在领域中发现了自然的层次结构, 就把它们转换为宽泛的抽象职责. 这些职责应该描述系统的高层目的和设计. 对模型进行重构, 使得每个领域对象, Aggregate和Module的职责都清晰地位于一个职责层当中. 即Responsibility Layer职责层.

层是对系统的划分, 每个层的元素都知道或能够使用在它下面的层的服务, 但却不知道它上面的层, 而且与它上面的层保持独立.

分层模式有一种变体最适合按职责来分层, 称为Relaxed Layered System松散分层系统. 如果采用这种分层模型, 某一层中的组件可以访问任何比它低的层, 而不限于只能访问直接与它相邻的下一层.

要想找到一种适当的Responsibility Layer或大比例结构, 需要理解问题领域并反复进行实验. 下面是一些指导方针.

当对层进行删除, 合并, 拆分和重新定义等操作时, 应寻找并保留以下一些有用的特征:

  1. 场景描述. 层应该能够表达出领域的基本现实或优先级. 层应该显示出业务的优先级.
  2. 概念依赖性. 较高层的概念意义应该依赖较低层, 而较低层概念的意义应该独立于较高的层.
  3. Conceptual Contour概念轮廓. 如果不同层的对象必须具有不同的变化频率或原因, 那么层应该能够容许它们之间的变化.

为每个新模型定义层不一定总要从头开始, 在一系列相关领域中, 有些层是固定的. 如潜能层作业层.

  • 潜能层: 企业的资源以及这些资源的组织方式是潜能层的核心.
  • 作业层: 应该反映出现实状况, 在该层中看到自己的工作和活动.

通常来说, 作业层对象可以引用潜能层对象, 甚至可以由潜能层对象组成, 但潜能层对象不应该直接引用作业层对象.

在很多现有的系统中, 这两个层可以涵盖一些对象. 但当项目要为用户提供指导或帮助或者要自动制定一些决策时, 就需要被组织到作业层之上的决策支持层.

决策支持层: 用来做出分析和制定决策的. 根据来自较低层的信息进行分析.

另一种情形是软件实施了详细的业务规则或法律需求, 这些规则或需求可以形成一个Responsibility Layer.

策略层: 规则和目标是被动的, 但它们约束着其他层的行为. 有时策略会作为一个参数传给较低层的方法. 有时会使用Strategy模式. 策略层与决策支持层能够进行很好的协作, 决策支持层提供了用于搜索策略层所设定的目标的方式, 这些目标又受到策略层所设定的规则的约束.

策略层可以和其他层使用同一种语言来编写, 但它们有时是使用规则引擎来实现的. 需要在两种不同的实现技术中严格使用同一种模型, 可以减少在两种实现技术之间进行协调的难度.

有些领域业务中, 潜能在很大程度上由当前的运营状况决定的, 此时潜能层可能会被合并到作业层中, 会演变出一种不同的分层结构.

承诺层: 对客户所作出的承诺. 这个层具有策略层的性质, 因为它表述了一些指导未来运营的目标; 但它也有作业层的性质, 因为承诺是作为后续业务活动的一部分而出现和变化的. 潜能层和承诺层并不是互相排斥的.

注意一定要使分层系统保持简单, 如果层数超过4或5就比较难处理了. 层数过多将无法有效地描述领域, 而且本来要使用大比例结构解决的复杂性问题又会以一种新的方式出现. 必须对大比例结构进行严格的精简.

虽然这5个层对很多企业系统都适用, 但并不是所有领域的主要概念都涵盖在这5个层中. 有些情况下, 在设计中生搬硬套用这种形式反而会起反作用, 而使用一组更自然的Responsibility Layer会更有效.

模式: Knowledge Level

当需要让用户对模型的一部分有所控制, 而模型又必须满足更大的一组规则时, 可以利用Knowledge Level知识级别来处理这种情况. Knowledge Level是一组描述了另一组对象应该有哪些行为的对象, 它可以使软件具有可配置的行为.

如果在一个应用程序中, Entity的角色和它们之间的关系在不同的情况下有很多变化, 那么复杂性会显著增加. 在这种情况下, 无论是一般的模型还是高度定制的模型, 都无法满足用户的需求. 为了兼容各种不同的情形, 对象需要引用其他的联系, 或者需要具备一些在不同情况下包括不同使用方式的属性. 具有相同数据和行为的类可能会大量增加, 而这些类的唯一作用只是为了满足不同的组装规则.

Knowledge Level模式相当于在模型中嵌入了一个描述模型的模型. Knowledge Level分离了模型的自我定义的方面, 并清楚地显示了它的限制.

Knowledge Level是Reflection反射模式在领域层中的一种应用. 但编程语言的反射工具并不是用于实现领域模型的Knowledge Level的. 这些元对象描述的是语言构造本身的结构和行为, 而Knowledge Level必须使用普通对象来构造.

创建一组不同的对象, 用它们来描述和约束基本模型的结构和行为. 把这些对象分为两个级别, 一个是非常具体的级别, 另一个级别则提供了一些可供用户或超级用户定制的规则和知识.

不应滥用Knowledge Level模式, 它确实能够使对象不必为了满足各种不同情形下的需求而变得过于复杂, 但它所引入的间接性也会使系统变得更模糊. 如果太复杂, 开发人员和用户就很难理解系统的行为. 而且当Knowledge Level中某个结构发生变化时, 必须对现有的操作级别中的对象进行相应的处理.

设计必须足够健壮, 不仅要解决开发中可能出现的各种问题, 而且还要考虑到将来用户在配置软件时可能会出现的各种问题. 如果系统中某些部分的定制非常关键, 而要是不提供定制能力就会破坏掉整个设计, 这时就可以采用Knowledge Level模式.

乍看上去, Knowledge Level像是Responsibility Layer策略层的一个特例, 但并不是. 首先Knowledge Level的两个级别的依赖性是双向的, 而在层次结构中, 较低的层不依赖较高的层.

模式: Pluggable Component Framework

在深入理解和反复精炼的基础上得到的成熟模型中会出现很多机会. 通常只有在同一个领域中实现了多个应用程序之后, 才有机会使用Pluggable Component Framework可插入式组件框架.

当很多应用程序需要进行互操作时, 如果所有应用程序都基于相同的一些抽象(如JDBC), 但它们是独立设计的, 那么在多个Bounded Context之间的转换会限制它们的集成. 各个团队之间如果不能紧密地协作, 就无法形成一个Shared Kernel. 重复和分裂将会增加开发和安装的成本, 而且互操作会变得很难实现.

一些项目把设计分解为组件, 每个组件提供某些类别的功能. 通常所有组件都插入到一个中央hub中, 这个hub支持组件所需的所有协议, 并且知道如何与组件提供的接口进行对话. 用于连接组件的hub必须要协调, 而组件内部的设计可以更独立一些.

从接口和交互中提炼出一个Abstract Core, 并创建一个框架, 这个框架要允许这些接口各种不同实现被自由替换. 同样无论是什么应用程序, 只要它严格地通过Abstract Code的接口进行操作, 那么就可以允许它使用这些组件.

Pluggable Component Framework有以下缺点:

  1. 它是一种非常难以使用的模式, 需要高精度的接口设计和一个非常深入的模型, 以便把一些必要的行为捕获到Abstract Code中.
  2. 它只为应用程序提供了有限的选择. 如果一个应用程序需要对Core Domain使用一种非常不同的方法, 那么可插入式组件框架将起到妨碍作用.

Pluggable Component Framework不适合作为项目的第一个大比例结构, 也不适合作为第二个, 最成功的例子都是在完全开发出了多个专门应用之后才采用这种结构的.

结构应该有一种什么样的约束

结构越严格, 一致性就越高, 设计也越容易理解. 如果结构适当的话, 规则将推动开发人员得出好的设计, 不同的部分之间更协调.

另一方面, 约束也会限制开发人员所需的灵活性. 在异构系统中, 特别是当系统使用了不同的实现技术时, 可能无法跨越不同的Bounded Context来使用非常特殊的通信路径.

因此一定要克制, 不要滥用框架和死板地实现大比例结构. 大比例结构的最重要贡献在于它具有概念上的一致性, 并帮助我们更深入地理解领域. 每条结构规则都应该使开发变得更容易实现.

通过重构得到更适当的结构

只有深入地理解领域和问题才能发现一种非常有用的结构, 而获得这种深刻的理解的有效方式就是迭代开发过程.

团队要想坚持Evolving Order原则, 必须在项目的整个生命周期中大胆地反复思考大比例结构. 团队不应该一成不变地使用早期构思出来的结构, 因为那时所有人对领域或需求的理解都不够完善.

必须在开发过程中进行重构, 以便得到最终的结构, 这可能很难实现, 而且需要高昂的代价, 但这样做是非常必要的. 有些通用的方法可以帮助控制成本并最大化收益.

最小化

控制成本的一个关键是保持一种简单, 轻量级的结构. 不要使结构面面俱到, 只需解决最主要的问题即可, 其他问题可以留到后面一个个解决.

开始最好选择一种松散的结构, 如System Metaphor或Responsibility Layer. 一种最小化的松散结构可以起到轻量级的指导作用, 有助于避免混乱.

沟通和自律

整个团队在新的开发和重构中必须遵守结构, 整个团队必须理解结构. 必须把术语和关系纳入到Ubiquitous Language中, 仅仅通过沟通是不足以保证系统中采用一致的大比例结构的.

通过重构得到柔性设计

对结构的任何修改都可能导致大量的重构工作出现. 随着系统复杂度的增加和人们理解的加深, 结构会不断演变. 每次修改结构时必须修改整个系统, 以便遵守新的秩序.

采用了大比例结构的设计往往比那些未采用的设计更容易转换. 对模型进行合理的转换, 不断增加的知识被合并到模型中, 更改的要点已经被识别出来, 并且更改也变得更加灵活, 同时模型中一些稳定的部分也得到了简化. 这样, 底层领域的更显著的Conceptual Contour就会在模型结构中浮现出来.

通过精炼可以减轻负担

对模型施加的另一项关键工作是持续精炼, 可以从各个方面减小修改结构的难度. 如果可能的话, 应该把支持性元素简单地定义层符合大比例结构的形式.

通过精炼和重构得到更深层的理解的原理也适用于大比例结构, 也是一种使系统的整体控制变得更容易, 更安全的手段.

领域驱动设计的综合运用

把大型结构与Bounded Context结合起来使用

战略设计的三个基本原则: 上下文, 精炼和大型结构; 并不是互相代替的, 而是互为补充, 并且以多种方式进行互动. 如一种大型结构可以存在于一个Bounded Context中, 也可以跨越多个Bounded Context存在, 并用于组织Context Map.

在很多项目中, 更大的挑战是理解怎样使各个不同的部分构成一个整体. 这些部分可能被划分到不同的Bounded Context中, 需要理解各个部分在整个集成系统中的作用, 然后就可以用大型结构来组织Context Map. 这种情况下, 结构的术语适用于整个项目.

假如项目采用Responsibility Layer模式, 但有一个遗留系统, 它的组织结构与你采用的大型结构不一致, 此时必须确定遗留系统在新结构中的位置. 遗留系统所提供的Service可以被限定到几个层中.

如果遗留子系统的功能是通过一个Facade来访问的, 那么设计时, 该Facade所提供的每个Service应该只在一个层中, 不跨越多个层.

每个Bounded Context都是其自己的命名空间, 因此在一个Context中可以使用一种结构来组织模型, 而在相邻的Context中则可以使用另一种结构, 然后再使用一种别的结构来组织Context Map. 但是使用过多的结构会损害大型结构作为项目统一概念集的价值.

将大型结构与精炼结合起来使用

大型结构和精炼也是互为补充的. 大型结构可以帮助解释Core Domain内部的关系以及Generic Subdomain之间的关系. 大型结构本身可能也是Core Domain的一个重要部分, 能够提炼出对软件所要解决的业务问题的基本理解. 当项目被划分为多个Bounded Context时, 这种理解尤其有用. 这样Core Domain的模型对象就不会具有过过多的含义.

首先评估

当对一个项目进行战略设计时, 首先需要清晰地评估现状:

  1. 画出Context Map. 必须保证Map的一致性, 不能有模棱两可的情况.
  2. 注意项目上的语言使用. 建立项目的Ubiquitous Language, 且语言足够丰富.
  3. 理解重点所在. 识别Core Domain. 编写Domain Vision Statement.
  4. 项目所采用的技术需要遵循Model-Driven Design.
  5. 团队开发人员需要具备必要的技能.
  6. 开发人员必须了解领域知识, 并对其感兴趣.

这些问题为我们提供了一个可靠的起点, 当知道了这些问题的初步答案后, 就会明白什么是最迫切需要解决的. 随着时间的推进, 可以得出更精炼的答案.

由谁制定决策

战略设计必须明确地应用于整个项目, 项目有很多组织方式, 但是要想使决策制定过程更有效, 需要注意一些基本问题.

从应用程序开发自动得出的结构

一个非常善于沟通, 懂得自律的团队在没有核心领导的情况下照样能够很好地工作, 他们能够遵循Evolving Order来达成一组共同遵守的原则, 这样就能够有机地形成一种秩序, 而不用靠命令来约束.

这是极限编程团队的典型模式. 从理论上讲, 任何一对儿编程人都可以根据自己的理解来完全自发地创建一种结构. 通常让团队中的一个人或几个人来承担大型结构的一些监管职责有利于保持结构统一.如果这位承担监管职责的非正式的领导人也是一位负责具体工作的开发人员, 而不是决策的唯一制定者, 那么这种方法特别有效.

多个团队使用同一种大型结构时, 密切相关的团队可以开始非正式的协作. 对这种大型结构, 每个应用程序团队都会产生各自的想法, 而其中一些具体选择会由一个非正式的委员会来讨论, 这个委员会由各个团队的代表组成. 在评估了这些选择对设计的影响之后, 委员会决定是采用它, 修改它, 还是放弃它. 这种安排要想发挥作用, 需要保证团队数目较少, 各个团队之间能够一致地保持彼此协调, 他们的设计能力大致相同, 而且他们的结构需求基本一致, 可以通过同一种大型结构来满足.

以客户为中心的架构团队

当几个团队共用同一种策略时, 确实需要集中制定一些决策. 架构师如果脱离实际开发工作, 就可能设计出失败的模型. 架构团队把之间放在与应用开发团队平等的位置上, 帮助他们协调大型结构, Bounded Context边界和技术难题. 为了在这个过程中发挥作用, 架构团队必须把思考重点放在应用程序的开发上.

架构团队的成员是真正的开发协作者, 与开发人员一起发现模式, 与各个团队一起通过反复实验进行精炼, 并亲自动手参与开发工作.

制定战略设计决策的6个要点

决策必须传达到整个团队

要求以架构团队为中心组织到一起, 以便在整个项目中应用一致的规则. 如果架构师没有实践经验, 又试图把他们自己的规则强加于实践的应用程序, 那么他们设计出来的模型会不切实际.

在一个沟通良好的项目中, 应用开发团队产生的策略设计实际上会更有效地传播到每个人. 这样策略将会实际发挥作用, 而且具有权威性, 因为是通过集体智慧制定的决策.

无论开发什么系统, 都不要用管理层所授予的权力来强制地推动战略决策, 而应该更多地关注开发人员与策略自己的实际关系.

决策过程必须收集反馈意见

应用开发团队的成员具有对项目的需求和领域概念的深层次知识和理解. 战略设计真正需要的是应用开发团队的参与. 经验丰富的架构师可以听取来自各个团队的想法, 并促进总体解决方案的开发.

计划必须允许演变

有效的软件开发是一个高度动态的过程, 遵循Evolving Order原则, 可以避免对变更做出响应时选择变少的问题, 因为该原则强调的是根据理解的不断加深来调整大型结构.

很多设计决策过早地固定下来, 开发人员会束手束脚, 失去解决问题的灵活性, 因此原则必须能够随着项目开发生命周期的进行而完善和变化, 且不能过分限制应用程序开发人员的能力.

有了积极的反馈后, 当构建应用程序的过程中遇到障碍或是出现了意想不到的机会时, 创建自然而然地涌现出来了.

架构团队不必把所有最好最聪明的人员都吸收进来

架构层次的设计确实需要技术精湛的人员, 但战略设计并不是一项纯粹的技术任务, 把那些精通深层次领域知识的开发人员排除在外只会使架构师的工作更难进行, 而且同样需要领域专家的参与.

所有应用程序团队都应该有一些技术能力很强的设计人员, 而且任何从事战略设计的团队也都必须具有领域知识, 这两者都是非常重要的.

战略设计需要遵循简约和谦逊的原则

任何设计工作都必须精炼且简约, 而战略设计尤为需要简约, 即使非常小的设计失误也有可能会变成可怕的隐患.

把架构团队单分出来时要格外慎重, 因为他们将更少感知他们为应用程序开发团队所设置的障碍. 同时架构师对其主要职责的过度关注会使他们迷失方向.

必须严格地约束自己, 从而使设计出来的组织原则和核心模型精简到只包含那些能够显著提高设计清晰度的内容. 需要有一个谦逊的态度, 才能认识到我们自己认为的最佳思路可能会妨碍其他人.

对象的职责要专一而开发人员应该是多面手

良好的对象设计的关键是为每个对象分配一个明确且专一的职责, 并且把对象之间的互相依赖减至最小.

在一个优秀的项目中, 会有很多人参与其他人的事情. 所有人员都可以互相交流, 看似混乱实则行之有效.

把战略设计与其他设计区分开是为了帮助澄清所涉及的工作, 但必须指出这两种设计活动并不意味着有两种人员.

虽然基于深层模型创建柔性设计是一种高级设计活动, 但细节问题也至关重要, 因此战略设计工作必须由接触编码工作的人来完成. 战略设计源自应用设计, 然而战略设计需要一个总体的开发活动视图, 这个视图可能跨越多个团队.

技术框架同样如此

技术框架提供了基础设施, 能够极大地加速应用程序的开发, 但技术框架也是有风险的, 那就是它会影响领域模型实现的表达能力, 并妨碍领域模型的自由改变.

用于克服战略设计缺点的原则同样适用于技术框架. 遵守演变, 简约等原则并且让应用程序开发团队参与进来, 能够得到一组持续精化的服务和规则, 这些服务和规则能够真正有助于应用程序的开发, 而不会妨碍开发.

不要编写傻瓜式的框架. 在划分团队时, 如果认为一些开发人员不够聪明, 无法胜任设计工作, 而让他们去做开发工作, 那么这种态度可能会导致失败, 因为他们低估了应用程序开发的难度. 如果这些人在设计方面不够聪明, 就不应该让他们来开发软件. 如果他们足够聪明, 那么这种隔离只会造成障碍, 使他们得不到所需的工具.

这种态度还会损害团队之间的关系, 让开发团队成员感到疲惫不堪(唉, 深有体会).

注意, 把无关的技术细节封装起来与作者反对的这种傻瓜式的预打包完全不同, 问问框架设计人员对使用框架的人有什么期望, 就可以看出区别.

程序员内功
码出好代码
  • 作者:lzlg520
  • 发表时间:2025-01-20 13:52
  • 版权声明:自由转载-非商用-非衍生-保持署名
  • 公众号转载:请在文末添加作者公众号二维码