单元测试原则、实践与模式(七)
译者声明:本译文仅作为学习的记录,不作为商业用途,如有侵权请告知我删除。
本文禁止转载!
请支持原书正版:https://www.manning.com/books/unit-testing
This chapter covers
- Recognizing the four types of code 认识四种类型的代码
- Understanding the Humble Object pattern 了解Humble Object模式
- Writing valuable tests 编写有价值的测试
In chapter 1, I defined the properties of a good unit test suite: 在第1章中,我定义了一个好的单元测试套件的属性:
- It is integrated into the development cycle. 它被集成到开发周期中。
- It targets only the most important parts of your code base. 它只针对你的代码库中最重要的部分。
- It provides maximum value with minimum maintenance costs. To achieve this last attribute, you need to be able to: 它以最小的维护成本提供最大的价值。为了实现这最后一个属性,你需要能够:
- Recognize a valuable test (and, by extension, a test of low value). 识别有价值的测试(以及延伸到低价值的测试)
- Write a valuable test. 编写一个有价值的测试。
Chapter 4 covered the topic of recognizing a valuable test using the four attributes: protection against regressions, resistance to refactoring, fast feedback, and maintainability. And chapter 5 expanded on the most important one of the four: resistance to refactoring.
第4章涵盖了识别有价值的测试的主题,使用了四个属性:防止回归,抗重构,快速反馈,和可维护性。而第五章则对这四个属性中最重要的一个进行了扩展:对重构的抵抗力。
As I mentioned earlier, it’s not enough to recognize valuable tests, you should also be able to write such tests. The latter skill requires the former, but it also requires that you know code design techniques. Unit tests and the underlying code are highly intertwined, and it’s impossible to create valuable tests without putting effort into the code base they cover.
正如我前面提到的,仅仅认识到有价值的测试是不够的,你还应该能够编写这样的测试。后者的技能需要前者,但它也需要你知道代码设计技术。单元测试和底层代码是高度交织在一起的,如果不在它们所覆盖的代码库中投入精力,就不可能创建有价值的测试。
You saw an example of a code base transformation in chapter 6, where we refactored an audit system toward a functional architecture and, as a result, were able to apply output-based testing. This chapter generalizes this approach onto a wider spectrum of applications, including those that can’t use a functional architecture. You’ll see practical guidelines on how to write valuable tests in almost any software project.
在第6章中,你看到了一个代码库改造的例子,我们将一个审计系统重构为一个功能架构,因此,能够应用基于输出的测试。本章将这种方法推广到更广泛的应用程序,包括那些不能使用功能架构的应用程序。你会看到如何在几乎所有的软件项目中编写有价值的测试的实用指南。
7.1 Identifying the code to refactor
It’s rarely possible to significantly improve a test suite without refactoring the underlying code. There’s no way around it—test and production code are intrinsically connected. In this section, you’ll see how to categorize your code into the four types in order to outline the direction of the refactoring. The subsequent sections show a comprehensive example.
在不重构底层代码的情况下,很少有可能显著改善测试套件。这是没办法的事—测试和生产代码是内在联系的。在本节中,你将看到如何将你的代码分为四种类型,以便勾勒出重构的方向。接下来的章节将展示一个全面的例子。
7.1.1 The four types of code
In this section, I describe the four types of code that serve as a foundation for the rest of this chapter. 在这一节中,我描述了四种类型的代码,作为本章其他部分的基础。
All production code can be categorized along two dimensions: 所有的生产代码都可以从两个方面进行分类:
- Complexity or domain significance 复杂度或领域意义
- The number of collaborators 合作者的数量
Code complexity is defined by the number of decision-making (branching) points in the code. The greater that number, the higher the complexity.
代码的复杂性是由代码中决策(分支)点的数量定义的。这个数字越大,复杂性就越高。
How to calculate cyclomatic complexity 如何计算循环复杂性
In computer science, there’s a special term that describes code complexity: cyclomatic complexity. Cyclomatic complexity indicates the number of branches in a given program or method. This metric is calculated as
在计算机科学中,有一个描述代码复杂性的特殊术语:循环复杂性。循环复杂性表示一个给定程序或方法中的分支数量。这个指标的计算方法是
1 + <number of branching points>Thus, a method with no control flow statements (such as if statements or conditional loops) has a cyclomatic complexity of 1 + 0 = 1
因此,一个没有控制流语句(如if语句或条件循环)的方法的循环复杂性为1+0=1
There’s another meaning to this metric. You can think of it in terms of the number of independent paths through the method from an entry to an exit, or the number of tests needed to get a 100% branch coverage.
这个度量还有另一个含义。你可以把它看成是方法从入口到出口的独立路径的数量,或者是获得100%分支覆盖率所需的测试数量。
Note that the number of branching points is counted as the number of simplest predicates involved. For instance, a statement like IF condition1 AND condition2 THEN … is equivalent to IF condition1 THEN IF condition2 THEN … Therefore, its complexity would be 1 + 2 = 3.
请注意,分支点的数量是以所涉及的最简单的谓词的数量来计算的。例如,像IF condition1 AND condition2 THEN … 这样的语句相当于IF condition1 THEN IF condition2 THEN … 因此,其复杂度为1+2=3。
Domain significance shows how significant the code is for the problem domain of your project. Normally, all code in the domain layer has a direct connection to the end users’ goals and thus exhibits a high domain significance. On the other hand, utility code doesn’t have such a connection.
领域重要性显示了代码对项目的问题领域的重要性。通常情况下,领域层的所有代码都与最终用户的目标有直接联系,因此表现出较高的领域重要性。另一方面,实用代码没有这样的联系。
Complex code and code that has domain significance benefit from unit testing the most because the corresponding tests have great protection against regressions. Note that the domain code doesn’t have to be complex, and complex code doesn’t have to exhibit domain significance to be test-worthy. The two components are independent of each other. For example, a method calculating an order price can contain no conditional statements and thus have the cyclomatic complexity of 1. Still, it’s important to test such a method because it represents business-critical functionality.
复杂的代码和具有领域意义的代码从单元测试中获益最大,因为相应的测试对回归有很大的保护作用。请注意,领域代码不一定是复杂的,复杂的代码也不一定要表现出领域意义才有测试价值。这两个部分是相互独立的。例如,一个计算订单价格的方法可以不包含任何条件语句,因此其循环复杂性为1。不过,测试这样的方法还是很重要的,因为它代表了关键的业务功能。
The second dimension is the number of collaborators a class or a method has. As you may remember from chapter 2, a collaborator is a dependency that is either mutable or out-of-process (or both). Code with a large number of collaborators is expensive to test. That’s due to the maintainability metric, which depends on the size of the test. It takes space to bring collaborators to an expected condition and then check their state or interactions with them afterward. And the more collaborators there are, the larger the test becomes.
第二个维度是一个类或一个方法的合作者的数量。你可能还记得第二章的内容,协作者是一种依赖关系,它要么是可变的,要么是进程外的(或者两者都是)。有大量协作者的代码测试起来很费劲。这是由于可维护性指标的原因,它取决于测试的大小。要使合作者达到预期的状态,然后检查它们的状态或交互性,这需要空间。
The type of the collaborators also matters. Out-of-process collaborators are a no-go when it comes to the domain model. They add additional maintenance costs due to the necessity to maintain complicated mock machinery in tests. You also have to be extra prudent and only use mocks to verify interactions that cross the application boundary in order to maintain proper resistance to refactoring (refer to chapter 5 for more details). It’s better to delegate all communications with out-of-process dependencies to classes outside the domain layer. The domain classes then will only work with in-process dependencies.
合作者的类型也很重要。当涉及到领域模型时,进程外的合作者是不允许的。它们会增加额外的维护成本,因为必须在测试中维护复杂的模拟机制。你还必须格外谨慎,只使用mock来验证跨越应用边界的交互,以保持对重构的适当抵抗力(更多细节请参考第五章)。最好是将所有与进程外依赖关系的通信委托给域层之外的类。然后领域类将只与进程内的依赖关系一起工作。
Notice that both implicit and explicit collaborators count toward this number. It doesn’t matter if the system under test (SUT) accepts a collaborator as an argument or refers to it implicitly via a static method, you still have to set up this collaborator in tests. Conversely, immutable dependencies (values or value objects) don’t count. Such dependencies are much easier to set up and assert against.
请注意,隐式和显式合作者都算在这个数字内。如果被测系统(SUT)接受协作器作为参数或通过静态方法隐式引用它,这并不重要,你仍然必须在测试中设置这个协作器。相反,不可变的依赖关系(值或值对象)不计。这样的依赖关系更容易设置和断言。
The combination of code complexity, its domain significance, and the number of collaborators give us the four types of code shown in figure 7.1: 代码的复杂性,它的领域意义,以及合作者的数量的组合给了我们图7.1中所示的四种类型的代码:
- Domain model and algorithms (figure 7.1, top left)—Complex code is often part of the domain model but not in 100% of all cases. You might have a complex algorithm that’s not directly related to the problem domain. 领域模型和算法(图7.1,左上角)—复杂的代码通常是领域模型的一部分,但不是100%的情况。你可能有一个复杂的算法,但与问题域没有直接关系。
- Trivial code (figure 7.1, bottom left)—Examples of such code in C# are parameterless constructors and one-line properties: they have few (if any) collaborators and exhibit little complexity or domain significance. 琐碎的代码(图7.1,左下)—C#中这类代码的例子是无参数构造函数和单行属性:它们的合作者很少(如果有的话),表现出很少的复杂性或领域意义。
- Controllers (figure 7.1, bottom right)—This code doesn’t do complex or businesscritical work by itself but coordinates the work of other components like domain classes and external applications. 控制器(图7.1,右下角)—这种代码本身并不做复杂或关键的业务工作,而是协调其他组件的工作,如领域类和外部应用程序。
- Overcomplicated code (figure 7.1, top right)—Such code scores highly on both metrics: it has a lot of collaborators, and it’s also complex or important. An example here are fat controllers (controllers that don’t delegate complex work anywhere and do everything themselves). 过度复杂的代码(图7.1,右上)—这种代码在两个指标上都得分很高:它有很多合作者,而且它也很复杂或重要。这里的一个例子是肥胖的控制器(不把复杂的工作委托给任何地方,自己做所有事情的控制器)。

Figure 7.1 The four types of code, categorized by code complexity and domain significance (the vertical axis) and the number of collaborators (the horizontal axis). 图7.1 四种类型的代码,按代码复杂性和领域重要性(纵轴)和合作者的数量(横轴)分类。
Unit testing the top-left quadrant (domain model and algorithms) gives you the best return for your efforts. The resulting unit tests are highly valuable and cheap. They’re valuable because the underlying code carries out complex or important logic, thus increasing tests’ protection against regressions. And they’re cheap because the code has few collaborators (ideally, none), thus decreasing tests’ maintenance costs.
左上角象限的单元测试(领域模型和算法)给你的努力带来最好的回报。由此产生的单元测试是非常有价值和便宜的。它们是有价值的,因为底层代码执行了复杂或重要的逻辑,从而增加了测试对回归的保护。它们很便宜,因为代码的合作者很少(最好是没有),因此减少了测试的维护成本。
Trivial code shouldn’t be tested at all; such tests have a close-to-zero value. As for controllers, you should test them briefly as part of a much smaller set of the overarching integration tests (I cover this topic in part 3).
琐碎的代码根本就不应该被测试;这样的测试有接近零的价值。至于控制器,你应该对其进行简短的测试,作为总体集成测试中更小的一部分(我在第3部分介绍了这个话题)。
The most problematic type of code is the overcomplicated quadrant. It’s hard to unit test but too risky to leave without test coverage. Such code is one of the main reasons many people struggle with unit testing. This whole chapter is primarily devoted to how you can bypass this dilemma. The general idea is to split overcomplicated code into two parts: algorithms and controllers (figure 7.2), although the actual implementation can be tricky at times.
最有问题的代码类型是过于复杂的象限。它很难进行单元测试,但在没有测试覆盖的情况下,风险太大。这样的代码是许多人在单元测试中挣扎的主要原因之一。这一整章主要是讨论如何绕过这个困境。一般的想法是将过于复杂的代码分成两部分:算法和控制器(图7.2),尽管实际的实现有时会很棘手。
TIP
The more important or complex the code, the fewer collaborators it should have.
代码越重要或越复杂,它的合作者就应该越少。
Getting rid of the overcomplicated code and unit testing only the domain model and algorithms is the path to a highly valuable, easily maintainable test suite. With this approach, you won’t have 100% test coverage, but you don’t need to—100% coverage shouldn’t ever be your goal. Your goal is a test suite where each test adds significant value to the project. Refactor or get rid of all other tests. Don’t allow them to inflate the size of your test suite.
摆脱过于复杂的代码,只对领域模型和算法进行单元测试,是获得高价值、易维护的测试套件的途径。用这种方法,你不会有100%的测试覆盖率,但你不需要—100%的覆盖率不应该是你的目标。你的目标是一个测试套件,每个测试都能为项目增加重要的价值。重构或摆脱所有其他测试。不要让他们扩大你的测试套件的规模。

Figure 7.2 Refactor overcomplicated code by splitting it into algorithms and controllers. Ideally, you should have no code in the top-right quadrant. 图7.2 通过将代码拆分为算法和控制器来重构过于复杂的代码。理想情况下,你应该在右上角的象限内没有代码。
NOTE
Remember that it’s better to not write a test at all than to write a bad test.
记住,与其写一个糟糕的测试,不如不写测试。
Of course, getting rid of overcomplicated code is easier said than done. Still, there are techniques that can help you do that. I’ll first explain the theory behind those techniques and then demonstrate them using a close-to-real-world example.
当然,摆脱过于复杂的代码,说起来容易做起来难。不过,还是有一些技术可以帮助你做到这一点。我将首先解释这些技术背后的理论,然后用一个接近真实世界的例子来证明它们。
7.1.2 Using the Humble Object pattern to split overcomplicated code
To split overcomplicated code, you need to use the Humble Object design pattern. This pattern was introduced by Gerard Meszaros in his book xUnit Test Patterns: Refactoring Test Code (Addison-Wesley, 2007) as one of the ways to battle code coupling, but it has a much broader application. You’ll see why shortly.
为了拆分过于复杂的代码,你需要使用Humble Object设计模式。这个模式是由Gerard Meszaros在他的书xUnit测试模式中介绍的: 重构测试代码》(Addison-Wesley,2007)一书中介绍的,作为与代码耦合作斗争的方法之一,但它有更广泛的应用。你很快就会看到原因。
We often find that code is hard to test because it’s coupled to a framework dependency (see figure 7.3). Examples include asynchronous or multi-threaded execution, user interfaces, communication with out-of-process dependencies, and so on.
我们经常发现,代码很难测试,因为它被耦合到一个框架的依赖关系中(见图7.3)。这方面的例子包括异步或多线程执行、用户界面、与进程外依赖的通信,等等。

Figure 7.3 It’s hard to test code that couples to a difficult dependency. Tests have to deal with that dependency, too, which increases their maintenance cost. 图7.3 很难测试那些与困难的依赖关系相耦合的代码。测试也必须处理这种依赖关系,这就增加了他们的维护成本。
To bring the logic of this code under test, you need to extract a testable part out of it. As a result, the code becomes a thin, humble wrapper around that testable part: it glues the hard-to-test dependency and the newly extracted component together, but itself contains little or no logic and thus doesn’t need to be tested (figure 7.4).
为了将这段代码的逻辑置于测试之下,你需要从其中提取一个可测试的部分。结果是,代码变成了一个薄薄的、卑微的、围绕着可测试部分的包装:它把难以测试的依赖关系和新提取的组件粘在一起,但它本身几乎不包含任何逻辑,因此不需要被测试(图7.4)。

Figure 7.4 The Humble Object pattern extracts the logic out of the overcomplicated code, making that code so humble that it doesn’t need to be tested. The extracted logic is moved into another class, decoupled from the hard-to-test dependency. 图7.4 谦逊的对象模式将逻辑从过于复杂的代码中提取出来,使代码变得非常谦逊,不需要进行测试。提取的逻辑被移到另一个类中,与难以测试的依赖关系解耦。
If this approach looks familiar, it’s because you already saw it in this book. In fact, both hexagonal and functional architectures implement this exact pattern. As you may remember from previous chapters, hexagonal architecture advocates for the separation of business logic and communications with out-of-process dependencies. This is what the domain and application services layers are responsible for, respectively.
如果这种方法看起来很熟悉,那是因为你已经在本书中看到了它。事实上,六边形架构和函数式架构都实现了这种确切的模式。你可能还记得前几章的内容,六边形架构主张将业务逻辑和通信与进程外的依赖关系分开。这就是领域层和应用服务层分别负责的事情。
Functional architecture goes even further and separates business logic from communications with all collaborators, not just out-of-process ones. This is what makes functional architecture so testable: its functional core has no collaborators. All dependencies in a functional core are immutable, which brings it very close to the vertical axis on the types-of-code diagram (figure 7.5).
功能性架构更进一步,将业务逻辑与所有合作者的通信分离,而不仅仅是进程外的合作者。这就是功能化架构的可测试性:其功能核心没有合作者。功能性核心中的所有依赖关系都是不可改变的,这使得它非常接近代码类型图(图7.5)中的垂直轴。

Figure 7.5 The functional core in a functional architecture and the domain layer in a hexagonal architecture reside in the top-left quadrant: they have few collaborators and exhibit high complexity and domain significance. The functional core is closer to the vertical axis because it has no collaborators. The mutable shell (functional architecture) and the application services layer (hexagonal architecture) belong to the controllers’ quadrant. 图7.5 功能性架构中的功能核心和六边形架构中的领域层位于左上角的象限内:它们的合作者很少,表现出高度的复杂性和领域重要性。功能核心更靠近纵轴,因为它没有合作者。可变外壳(功能架构)和应用服务层(六边形架构)属于控制器的象限。
Another way to view the Humble Object pattern is as a means to adhere to the Single Responsibility principle, which states that each class should have only a single responsibility.1 One such responsibility is always business logic; the pattern can be applied to segregate that logic from pretty much anything.
另一种看待Humble Object模式的方式是作为遵守单一责任原则的一种手段,该原则指出每个类应该只承担单一的责任。
In our particular situation, we are interested in the separation of business logic and orchestration. You can think of these two responsibilities in terms of code depth versus code width. Your code can be either deep (complex or important) or wide (work with many collaborators), but never both (figure 7.6).
在我们的特定情况下,我们对业务逻辑和协调的分离感兴趣。你可以从代码深度与代码宽度的角度来考虑这两种责任。你的代码可以是深的(复杂的或重要的),也可以是宽的(与许多合作者一起工作),但绝不是两者都有(图7.6)。

Figure 7.6 Code depth versus code width is a useful metaphor to apply when you think of the separation between the business logic and orchestration responsibilities. Controllers orchestrate many dependencies (represented as arrows in the figure) but aren’t complex on their own (complexity is represented as block height). Domain classes are the opposite of that. 图7.6 代码深度与代码宽度的关系当你考虑到业务逻辑和协调责任之间的分离时,可以应用一个有用的比喻。控制器协调了许多依赖关系(图中以箭头表示),但其本身并不复杂(复杂性以块的高度表示)。 领域类则与此相反。
I can’t stress enough how important this separation is. In fact, many well-known principles and patterns can be described as a form of the Humble Object pattern: they are designed specifically to segregate complex code from the code that does orchestration.
我怎么强调这种分离有多重要都不为过。事实上,许多著名的原则和模式都可以被描述为谦卑对象模式的一种形式:它们被专门设计为将复杂的代码与进行协调的代码隔离开来。
You already saw the relationship between this pattern and hexagonal and functional architectures. Other examples include the Model-View-Presenter (MVP) and the Model-View-Controller (MVC) patterns. These two patterns help you decouple business logic (the Model part), UI concerns (the View), and the coordination between them (Presenter or Controller). The Presenter and Controller components are humble objects: they glue the view and the model together.
你已经看到了这种模式与六边形和功能架构之间的关系。其他的例子包括模型-视图-展示者(MVP)和模型-视图-控制器(MVC)模式。这两种模式帮助你将业务逻辑(模型部分)、UI关注点(视图)以及它们之间的协调(展示者或控制器)解耦。Presenter和Controller组件是卑微的对象:它们将视图和模型粘合在一起。
Another example is the Aggregate pattern from Domain-Driven Design. One of its goals is to reduce connectivity between classes by grouping them into clusters— aggregates. The classes are highly connected inside those clusters, but the clusters themselves are loosely coupled. Such a structure decreases the total number of communications in the code base. The reduced connectivity, in turn, improves testability.
另一个例子是来自领域驱动设计的聚合模式。它的目标之一是通过将类分组为群组—聚合体来减少类之间的连接。类在这些集群中是高度连接的,但集群本身是松散耦合的。这样的结构减少了代码库中通信的总数量。减少的连接性反过来又提高了可测试性。
Note that improved testability is not the only reason to maintain the separation between business logic and orchestration. Such a separation also helps tackle code complexity, which is crucial for project growth, too, especially in the long run. I personally always find it fascinating how a testable design is not only testable but also easy to maintain.
请注意,提高可测试性并不是保持业务逻辑和协调之间分离的唯一原因。这样的分离也有助于解决代码的复杂性,这对于项目的发展也是至关重要的,尤其是在长期内。我个人一直认为,一个可测试的设计不仅是可测试的,而且是容易维护的,这一点很吸引人。
7.2 Refactoring toward valuable unit tests
In this section, I’ll show a comprehensive example of splitting overcomplicated code into algorithms and controllers. You saw a similar example in the previous chapter, where we talked about output-based testing and functional architecture. This time, I’ll generalize this approach to all enterprise-level applications, with the help of the Humble Object pattern. I’ll use this project not only in this chapter but also in the subsequent chapters of part 3.
在本节中,我将展示一个将过于复杂的代码分割成算法和控制器的综合例子。你在上一章看到了一个类似的例子,我们在那里谈到了基于输出的测试和功能架构。这一次,我将在Humble Object模式的帮助下,将这种方法推广到所有的企业级应用。我不仅会在本章中使用这个项目,也会在第三部分的后续章节中使用。
7.2.1 Introducing a customer management system
The sample project is a customer management system (CRM) that handles user registrations. All users are stored in a database. The system currently supports only one use case: changing a user’s email. There are three business rules involved in this operation:
这个示例项目是一个处理用户注册的客户管理系统(CRM)。所有的用户都存储在一个数据库中。该系统目前只支持一个用例:改变用户的电子邮件。在这个操作中涉及三个业务规则:
- If the user’s email belongs to the company’s domain, that user is marked as an employee. Otherwise, they are treated as a customer. 如果用户的电子邮件属于公司的域名,该用户将被标记为雇员。否则,他们就被当作客户对待。
- The system must track the number of employees in the company. If the user’s type changes from employee to customer, or vice versa, this number must change, too. 系统必须跟踪公司的员工数量。如果用户的类型从雇员变为客户,或者相反,这个数字也必须改变。
- When the email changes, the system must notify external systems by sending a message to a message bus. 当电子邮件发生变化时,系统必须通过向消息总线发送一条消息来通知外部系统。
The following listing shows the initial implementation of the CRM system. 下面的列表显示了CRM系统的初始实现。
public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public void ChangeEmail(int userId, string newEmail)
{
object[] data = Database.GetUserById(userId); // Retrieves the user’s current email and type from the database 从数据库中检索用户的当前电子邮件和类型
UserId = userId;
Email = (string)data[1];
Type = (UserType)data[2];
if (Email == newEmail)
return;
object[] companyData = Database.GetCompany(); // Retrieves the organization’s domain name and the number of employees from the database 从数据库中检索组织的域名和员工人数
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];
string emailDomain = newEmail.Split('@')[1];
bool isEmailCorporate = emailDomain == companyDomainName;
UserType newType = isEmailCorporate ? UserType.Employee : UserType.Customer; // Sets the user type depending on the new email’s domain name
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
int newNumber = numberOfEmployees + delta;
Database.SaveCompany(newNumber); // Updates the number of employees in the organization, if needed
}
Email = newEmail;
Type = newType;
Database.SaveUser(this); // Persists the user in the database
MessageBus.SendEmailChangedMessage(UserId, newEmail); // Sends a notificationto the message bus
}
}
public enum UserType
{
Customer = 1,
Employee = 2
}
The User class changes a user email. Note that, for brevity, I omitted simple validations such as checks for email correctness and user existence in the database. Let’s analyze this implementation from the perspective of the types-of-code diagram.
用户类改变了一个用户的电子邮件。注意,为了简洁起见,我省略了一些简单的验证,比如检查电子邮件的正确性和数据库中用户的存在性。让我们从代码类型图的角度分析一下这个实现。
The code’s complexity is not too high. The ChangeEmail method contains only a couple of explicit decision-making points: whether to identify the user as an employee or a customer, and how to update the company’s number of employees. Despite being simple, these decisions are important: they are the application’s core business logic. Hence, the class scores highly on the complexity and domain significance dimension.
这段代码的复杂性并不高。ChangeEmail方法只包含几个明确的决策点:是否将用户识别为雇员或客户,以及如何更新公司的雇员人数。尽管很简单,但这些决策很重要:它们是应用程序的核心业务逻辑。因此,该类在复杂性和领域重要性方面得分很高。
On the other hand, the User class has four dependencies, two of which are explicit and the other two of which are implicit. The explicit dependencies are the userId and newEmail arguments. These are values, though, and thus don’t count toward the class’s number of collaborators. The implicit ones are Database and MessageBus. These two are out-of-process collaborators. As I mentioned earlier, out-of-process collaborators are a no-go for code with high domain significance. Hence, the User class scores highly on the collaborators dimension, which puts this class into the overcomplicated category (figure 7.7).
另一方面,用户类有四个依赖关系,其中两个是显性的,另外两个是隐性的。显性依赖是userId和newEmail参数。不过,这些都是值,因此不计入该类的合作者数量。隐式的是数据库和MessageBus。这两个是进程外的协作者。正如我前面提到的,进程外的协作者对于具有高领域重要性的代码来说是不允许的。因此,User类在协作者维度上得分很高,这使得这个类被归入过度复杂的类别(图7.7)。

Figure 7.7 The initial implementation of the User class scores highly on both dimensions and thus falls into the category of overcomplicated code. 图7.7 用户类的初始实现在这两个方面的得分都很高,因此属于过于复杂的代码范畴。
This approach—when a domain class retrieves and persists itself to the database— is called the Active Record pattern. It works fine in simple or short-lived projects but often fails to scale as the code base grows. The reason is precisely this lack of separation between these two responsibilities: business logic and communication with out-ofprocess dependencies.
这种方法—当一个领域类检索和持久化自己到数据库时—被称为Active Record模式。它在简单或短暂的项目中运行良好,但随着代码库的增长,往往无法扩展。原因正是因为这两种责任之间缺乏分离:业务逻辑和与进程外依赖关系的通信。
7.2.2 Take 1: Making implicit dependencies explicit
The usual approach to improve testability is to make implicit dependencies explicit: that is, introduce interfaces for Database and MessageBus, inject those interfaces into User, and then mock them in tests. This approach does help, and that’s exactly what we did in the previous chapter when we introduced the implementation with mocks for the audit system. However, it’s not enough.
提高可测试性的通常方法是使隐性依赖显性化:也就是说,引入数据库和MessageBus的接口,将这些接口注入User,然后在测试中模拟它们。这种方法确实有帮助,这正是我们在上一章介绍审计系统的模拟实现时所做的。然而,这还远远不够。
From the perspective of the types-of-code diagram, it doesn’t matter if the domain model refers to out-of-process dependencies directly or via an interface. Such dependencies are still out-of-process; they are proxies to data that is not yet in memory. You still need to maintain complicated mock machinery in order to test such classes, which increases the tests’ maintenance costs. Moreover, using mocks for the database dependency would lead to test fragility (we’ll discuss this in the next chapter).
从代码类型图的角度来看,领域模型是直接引用进程外的依赖关系还是通过接口引用并不重要。这种依赖仍然是进程外的;它们是尚未在内存中的数据的代理。为了测试这些类,你仍然需要维护复杂的模拟机制,这增加了测试的维护成本。此外,对数据库的依赖性使用mock会导致测试的脆弱性(我们将在下一章讨论这个问题)。
Overall, it’s much cleaner for the domain model not to depend on out-of-process collaborators at all, directly or indirectly (via an interface). That’s what the hexagonal architecture advocates as well—the domain model shouldn’t be responsible for communications with external systems.
总的来说,对于领域模型来说,直接或间接地(通过接口)不依赖进程外的合作者要干净得多。这也是六边形架构所倡导的—领域模型不应该负责与外部系统的通信。
7.2.3 Take 2: Introducing an application services layer
To overcome the problem of the domain model directly communicating with external systems, we need to shift this responsibility to another class, a humble controller (an application service, in the hexagonal architecture taxonomy). As a general rule, domain classes should only depend on in-process dependencies, such as other domain classes, or plain values. Here’s what the first version of that application service looks like.
为了克服领域模型直接与外部系统通信的问题,我们需要把这个责任转移到另一个类,即谦卑的控制器(应用服务,在六边形架构分类学中)。一般来说,领域类应该只依赖于过程中的依赖关系,如其他领域类,或普通的值。下面是该应用服务的第一个版本的样子。
Listing 7.2 Application service, version 1
public class UserController
{
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
public void ChangeEmail(int userId, string newEmail)
{
object[] data = _database.GetUserById(userId);
string email = (string)data[1];
UserType type = (UserType)data[2];
var user = new User(userId, email, type);
object[] companyData = _database.GetCompany();
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];
int newNumberOfEmployees = user.ChangeEmail(
newEmail, companyDomainName, numberOfEmployees);
_database.SaveCompany(newNumberOfEmployees);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
}
}
This is a good first try; the application service helped offload the work with out-ofprocess dependencies from the User class. But there are some issues with this implementation: 这是一个很好的第一次尝试;应用服务帮助卸载了用户类的进程外依赖性的工作。但是这个实现也有一些问题:
- The out-of-process dependencies (Database and MessageBus) are instantiated directly, not injected. That’s going to be a problem for the integration tests we’ll be writing for this class. 进程外的依赖(数据库和MessageBus)是直接实例化的,而不是注入的。这对我们为这个类编写的集成测试来说是个问题。
- The controller reconstructs a User instance from the raw data it receives from the database. This is complex logic and thus shouldn’t belong to the application service, whose sole role is orchestration, not logic of any complexity or domain significance. 控制器从数据库收到的原始数据中重新构建一个用户实例。这是一个复杂的逻辑,因此不应该属于应用服务,它的唯一作用是协调,而不是任何复杂或领域意义的逻辑。
- The same is true for the company’s data. The other problem with that data is that User now returns an updated number of employees, which doesn’t look right. The number of company employees has nothing to do with a specific user. This responsibility should belong elsewhere. 对于公司的数据也是如此。该数据的另一个问题是,User现在返回一个更新的雇员人数,这看起来并不正确。公司员工的数量与特定的用户没有关系。这个责任应该属于其他地方。
- The controller persists modified data and sends notifications to the message bus unconditionally, regardless of whether the new email is different than the previous one. 控制器持久化修改过的数据,并无条件地将通知发送到消息总线上,无论新邮件是否与之前的不同。
The User class has become quite easy to test because it no longer has to communicate with out-of-process dependencies. In fact, it has no collaborators whatsoever—out-ofprocess or not. Here’s the new version of User’s ChangeEmail method:
用户类已经变得相当容易测试,因为它不再需要与进程外的依赖关系进行通信。事实上,它没有任何合作者,不管是不是进程外的。下面是新版本的User的ChangeEmail方法:
public int ChangeEmail(string newEmail,
string companyDomainName, int numberOfEmployees)
{
if (Email == newEmail)
return numberOfEmployees;
string emailDomain = newEmail.Split('@')[1];
bool isEmailCorporate = emailDomain == companyDomainName;
UserType newType = isEmailCorporate
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
int newNumber = numberOfEmployees + delta;
numberOfEmployees = newNumber;
}
Email = newEmail;
Type = newType;
return numberOfEmployees;
}
Figure 7.8 shows where User and UserController currently stand in our diagram. User has moved to the domain model quadrant, close to the vertical axis, because it no longer has to deal with collaborators. UserController is more problematic. Although I’ve put it into the controllers quadrant, it almost crosses the boundary into overcomplicated code because it contains logic that is quite complex. 图7.8显示了User和UserController目前在我们图中的位置。User已经移到了领域模型象限,靠近纵轴,因为它不再需要处理合作者。UserController就比较麻烦了。虽然我把它放到了控制器象限,但它几乎跨越了边界,变成了过于复杂的代码,因为它包含的逻辑相当复杂。

Figure 7.8 Take 2 puts User in the domain model quadrant, close to the vertical axis. UserController almost crosses the boundary with the overcomplicated quadrant because it contains complex logic. 图7.8 以2把User放在领域模型象限,靠近纵轴。UserController几乎跨越了过度复杂象限的边界,因为它包含复杂的逻辑。
7.2.4 Take 3: Removing complexity from the application service
To put UserController firmly into the controllers quadrant, we need to extract the reconstruction logic from it. If you use an object-relational mapping (ORM) library to map the database into the domain model, that would be a good place to which to attribute the reconstruction logic. Each ORM library has a dedicated place where you can specify how your database tables should be mapped to domain classes, such as attributes on top of those domain classes, XML files, or files with fluent mappings.
为了把UserController牢牢地放到控制器象限中,我们需要从它身上提取出重构逻辑。如果你使用对象关系映射(ORM)库将数据库映射到领域模型中,这将是一个很好的地方,可以将重建逻辑归入其中。每个ORM库都有一个专门的地方,你可以指定你的数据库表应该如何被映射到领域类,比如那些领域类上面的属性,XML文件,或者有流畅映射的文件。
If you don’t want to or can’t use an ORM, create a factory in the domain model that will instantiate the domain classes using raw database data. This factory can be a separate class or, for simpler cases, a static method in the existing domain classes. The reconstruction logic in our sample application is not too complicated, but it’s good to keep such things separated, so I’m putting it in a separate UserFactory class as shown in the following listing.
如果你不想或不能使用ORM,可以在领域模型中创建一个工厂,使用原始数据库数据来实例化领域类。这个工厂可以是一个单独的类,或者在更简单的情况下,是现有领域类中的一个静态方法。我们的示例程序中的重构逻辑并不复杂,但把这种东西分开是很好的,所以我把它放在一个单独的UserFactory类中,如下面的列表所示。
Listing 7.3 User factory
public class UserFactory
{
public static User Create(object[] data)
{
Precondition.Requires(data.Length >= 3);
int id = (int)data[0];
string email = (string)data[1];
UserType type = (UserType)data[2];
return new User(id, email, type);
}
}
This code is now fully isolated from all collaborators and therefore easily testable. Notice that I’ve put a safeguard in this method: a requirement to have at least three elements in the data array. Precondition is a simple custom class that throws an exception if the Boolean argument is false. The reason for this class is the more succinct code and the condition inversion: affirmative statements are more readable than negative ones. In our example, the data.Length >= 3 requirement reads better than
这段代码现在已经与所有合作者完全隔离,因此很容易测试。注意,我在这个方法中加入了一个保障措施:要求数据数组中至少有三个元素。Precondition是一个简单的自定义类,如果布尔参数为假,则抛出一个异常。这个类的原因是代码更简洁,条件反转:肯定的语句比否定的语句更易读。在我们的例子中,data.Length >= 3的要求比以下要求更好读
if (data.Length < 3) throw new Exception();
Note that while this reconstruction logic is somewhat complex, it doesn’t have domain significance: it isn’t directly related to the client’s goal of changing the user email. It’s an example of the utility code I refer to in previous chapters.
请注意,虽然这个重构逻辑有些复杂,但它并不具有领域意义:它与客户改变用户电子邮件的目标没有直接关系。这是我在前几章提到的实用代码的一个例子。
How is the reconstruction logic complex?
How is the reconstruction logic complex, given that there’s only a single branching point in the UserFactory.Create() method? As I mentioned in chapter 1, there could be a lot of hidden branching points in the underlying libraries used by the code and thus a lot of potential for something to go wrong. This is exactly the case for the UserFactory.Create() method.
鉴于UserFactory.Create()方法中只有一个分支点,重建逻辑如何复杂?正如我在第1章中提到的,在代码所使用的底层库中可能有很多隐藏的分支点,因此有很多出错的可能性。这正是UserFactory.Create()方法的情况。
Referring to an array element by index (data[0]) entails an internal decision made by the .NET Framework as to what data element to access. The same is true for the conversion from object to int or string. Internally, the .NET Framework decides whether to throw a cast exception or allow the conversion to proceed. All these hidden branches make the reconstruction logic test-worthy, despite the lack of decision points in it.
通过索引(data[0])引用一个数组元素,需要由.NET框架作出内部决定,以访问什么数据元素。从对象到int或string的转换也是如此。在内部,.NET框架决定是抛出一个铸造异常还是允许转换继续进行。所有这些隐藏的分支使重建逻辑具有测试价值,尽管其中缺乏决策点。
7.2.5 Take 4: Introducing a new Company class
Look at this code in the controller once again:
object[] companyData = _database.GetCompany();
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];
int newNumberOfEmployees = user.ChangeEmail(
newEmail, companyDomainName, numberOfEmployees);
The awkwardness of returning an updated number of employees from User is a sign of a misplaced responsibility, which itself is a sign of a missing abstraction. To fix this, we need to introduce another domain class, Company, that bundles the companyrelated logic and data together, as shown in the following listing
从User返回更新的雇员人数的尴尬是一个责任错位的标志,这本身就是一个抽象缺失的标志。为了解决这个问题,我们需要引入另一个领域类,即Company,它将公司相关的逻辑和数据捆绑在一起,如下面的列表所示
Listing 7.4 The new class in the domain layer
public class Company
{
public string DomainName { get; private set; }
public int NumberOfEmployees { get; private set; }
public void ChangeNumberOfEmployees(int delta)
{
Precondition.Requires(NumberOfEmployees + delta >= 0);
NumberOfEmployees += delta;
}
public bool IsEmailCorporate(string email)
{
string emailDomain = email.Split('@')[1];
return emailDomain == DomainName;
}
}
There are two methods in this class: ChangeNumberOfEmployees() and IsEmailCorporate(). These methods help adhere to the tell-don’t-ask principle I mentioned in chapter 5. This principle advocates for bundling together data and operations on that data. A User instance will tell the company to change its number of employees or figure out whether a particular email is corporate; it won’t ask for the raw data and do everything on its own.
该类中有两个方法: ChangeNumberOfEmployees()和IsEmailCorporate()。这些方法有助于遵守我在第五章中提到的 “tell-don’t-ask”原则。这个原则主张将数据和对该数据的操作捆绑在一起。一个User实例会告诉公司改变其雇员人数,或者弄清楚某个特定的电子邮件是否是公司的;它不会询问原始数据并自己做所有的事情。
There’s also a new CompanyFactory class, which is responsible for the reconstruction of Company objects, similar to UserFactory. This is how the controller now looks.
还有一个新的CompanyFactory类,它负责重构Company对象,类似于UserFactory。这就是控制器现在的样子。
Listing 7.5 Controller after refactoring
public class UserController
{
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
public void ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
}
}
And here’s the User class.
Listing 7.6 User after refactoring
public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public void ChangeEmail(string newEmail, Company company)
{
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
}
Email = newEmail;
Type = newType;
}
}
Notice how the removal of the misplaced responsibility made User much cleaner. Instead of operating on company data, it accepts a Company instance and delegates two important pieces of work to that instance: determining whether an email is corporate and changing the number of employees in the company
注意到去除错位的责任后,User变得更干净了。它不再对公司数据进行操作,而是接受一个 Company 实例,并将两项重要的工作委托给该实例:确定电子邮件是否是公司的,以及改变公司的雇员人数。
Figure 7.9 shows where each class stands in the diagram. The factories and both domain classes reside in the domain model and algorithms quadrant. User has moved to the right because it now has one collaborator, Company, whereas previously it had none. That has made User less testable, but not much.
图7.9显示了每个类在图中的位置。工厂和两个领域类都位于领域模型和算法象限中。User移到了右边,因为它现在有一个合作者,Company,而以前它没有。这使得User的可测试性降低了,但也不多。

Figure 7.9 User has shifted to the right because it now has the Company collaborator. UserController firmly stands in the controllers quadrant; all its complexity has moved to the factories. 图7.9 用户已经移到了右边,因为它现在有了公司的合作者。UserController牢牢地站在控制器象限中;它的所有复杂性都转移到了工厂中。
UserController now firmly stands in the controllers quadrant because all of its complexity has moved to the factories. The only thing this class is responsible for is gluing together all the collaborating parties.
UserController现在牢牢地站在控制器象限里,因为它所有的复杂性都转移到了工厂里。这个类唯一负责的事情是把所有合作方粘在一起。
Note the similarities between this implementation and the functional architecture from the previous chapter. Neither the functional core in the audit system nor the domain layer in this CRM (the User and Company classes) communicates with out-ofprocess dependencies. In both implementations, the application services layer is responsible for such communication: it gets the raw data from the filesystem or from the database, passes that data to stateless algorithms or the domain model, and then persists the results back to the data storage.
注意这个实现和上一章的功能架构之间的相似之处。审计系统中的功能核心和该CRM中的领域层(用户和公司类)都没有与进程外的依赖关系进行通信。在这两种实现中,应用服务层负责这种通信:它从文件系统或数据库中获取原始数据,将这些数据传递给无状态算法或领域模型,然后将结果持久化到数据存储中。
The difference between the two implementations is in their treatment of side effects. The functional core doesn’t incur any side effects whatsoever. The CRM’s domain model does, but all those side effects remain inside the domain model in the form of the changed user email and the number of employees. The side effects only cross the domain model’s boundary when the controller persists the User and Company objects in the database.
这两种实现的区别在于它们对副作用的处理。功能核心不会产生任何副作用。CRM的领域模型有,但所有这些副作用都保留在领域模型内,以改变的用户电子邮件和雇员人数的形式存在。只有当控制器在数据库中持久化用户和公司对象时,副作用才会跨越域模型的边界。
The fact that all side effects are contained in memory until the very last moment improves testability a lot. Your tests don’t need to examine out-of-process dependencies, nor do they need to resort to communication-based testing. All the verification can be done using output-based and state-based testing of objects in memory.
事实上,所有的副作用都包含在内存中,直到最后一刻,这大大提高了可测试性。你的测试不需要检查进程外的依赖关系,也不需要求助于基于通信的测试。所有的验证都可以使用基于输出和基于状态的内存中的对象测试来完成。
7.3 Analysis of optimal unit test coverage
Now that we’ve completed the refactoring with the help of the Humble Object pattern, let’s analyze which parts of the project fall into which code category and how those parts should be tested. Table 7.1 shows all the code from the sample project grouped by position in the types-of-code diagram.
现在我们已经在Humble Object模式的帮助下完成了重构,让我们分析一下项目的哪些部分属于哪种代码类型,这些部分应该如何测试。表7.1显示了样本项目的所有代码,按代码类型图中的位置进行分组。
Table 7.1 Types of code in the sample project after refactoring using the Humble Object pattern

With the full separation of business logic and orchestration at hand, it’s easy to decide which parts of the code base to unit test.
7.3.1 Testing the domain layer and utility code
Testing methods in the top-left quadrant in table 7.1 provides the best results in costbenefit terms. The code’s high complexity or domain significance guarantees great protection against regressions, while having few collaborators ensures the lowest maintenance costs. This is an example of how User could be tested:
表7.1中左上角象限的测试方法在成本效益方面提供了最好的结果。代码的高复杂性或领域的重要性保证了对回归的极大保护,而合作者少又保证了最低的维护成本。这是一个关于用户如何被测试的例子:
[Fact]
public void Changing_email_from_non_corporate_to_corporate()
{
var company = new Company("mycorp.com", 1);
var sut = new User(1, "user@gmail.com", UserType.Customer);
sut.ChangeEmail("new@mycorp.com", company);
Assert.Equal(2, company.NumberOfEmployees);
Assert.Equal("new@mycorp.com", sut.Email);
Assert.Equal(UserType.Employee, sut.Type);
}
To achieve full coverage, you’d need another three such tests:
public void Changing_email_from_corporate_to_non_corporate()
public void Changing_email_without_changing_user_type()
public void Changing_email_to_the_same_one()
Tests for the other three classes would be even shorter, and you could use parameterized tests to group several test cases together:
[InlineData("mycorp.com", "email@mycorp.com", true)]
[InlineData("mycorp.com", "email@gmail.com", false)]
[Theory]
public void Differentiates_a_corporate_email_from_non_corporate(
string domain, string email, bool expectedResult)
{
var sut = new Company(domain, 0);
bool isEmailCorporate = sut.IsEmailCorporate(email);
Assert.Equal(expectedResult, isEmailCorporate);
}
7.3.2 Testing the code from the other three quadrants
Code with low complexity and few collaborators (bottom-left quadrant in table 7.1) is represented by the constructors in User and Company, such as
public User(int userId, string email, UserType type)
{
UserId = userId;
Email = email;
Type = type;
}
These constructors are trivial and aren’t worth the effort. The resulting tests wouldn’t provide great enough protection against regressions.
The refactoring has eliminated all code with high complexity and a large number of collaborators (top-right quadrant in table 7.1), so we have nothing to test there, either. As for the controllers quadrant (bottom-right in table 7.1), we’ll discuss testing it in the next chapter.
7.3.3 Should you test preconditions?
Let’s take a look at a special kind of branching points—preconditions—and see whether you should test them. For example, look at this method from Company once again:
public void ChangeNumberOfEmployees(int delta)
{
Precondition.Requires(NumberOfEmployees + delta >= 0);
NumberOfEmployees += delta;
}
It has a precondition stating that the number of employees in the company should never become negative. This precondition is a safeguard that’s activated only in exceptional cases. Such exceptional cases are usually the result of bugs. The only possible reason for the number of employees to go below zero is if there’s an error in code. The safeguard provides a mechanism for your software to fail fast and to prevent the error from spreading and being persisted in the database, where it would be much harder to deal with. Should you test such preconditions? In other words, would such tests be valuable enough to have in the test suite?
它有一个前提条件,即公司的雇员人数不应成为负数。这个前提条件是一种保障,只有在特殊情况下才会激活。这种特殊情况通常是错误的结果。雇员人数低于零的唯一可能原因是代码中存在错误。该保障措施为你的软件提供了一个快速失败的机制,并防止错误扩散和在数据库中持续存在,在那里它将更难处理。你应该测试这样的前提条件吗?换句话说,这样的测试是否有足够的价值,可以放在测试套件中?
There’s no hard rule here, but the general guideline I recommend is to test all preconditions that have domain significance. The requirement for the non-negative number of employees is such a precondition. It’s part of the Company class’s invariants: conditions that should be held true at all times. But don’t spend time testing preconditions that don’t have domain significance. For example, UserFactory has the following safeguard in its Create method:
这里没有硬性规定,但我推荐的一般准则是测试所有具有领域意义的前提条件。雇员人数为非负数的要求就是这样一个前提条件。它是公司类不变量的一部分:在任何时候都应该保持真实的条件。但不要花时间测试那些没有领域意义的前提条件。例如,UserFactory在其创建方法中有如下保障:
public static User Create(object[] data)
{
Precondition.Requires(data.Length >= 3);
/* Extract id, email, and type out of data */
}
There’s no domain meaning to this precondition and therefore not much value in testing it.
这个前提条件没有任何领域意义,因此测试它的价值不大。
7.4 Handling conditional logic in controllers
Handling conditional logic and simultaneously maintaining the domain layer free of out-of-process collaborators is often tricky and involves trade-offs. In this section, I’ll show what those trade-offs are and how to decide which of them to choose in your own project.
处理条件逻辑并同时保持领域层没有进程外的合作者往往是很棘手的,并涉及到权衡。在这一节中,我将说明这些权衡是什么,以及如何决定在你自己的项目中选择哪种权衡。
The separation between business logic and orchestration works best when a business operation has three distinct stages
当一个业务操作有三个不同的阶段时,业务逻辑和协调之间的分离效果最好
- Retrieving data from storage 从存储中检索数据
- Executing business logic 执行业务逻辑
- Persisting data back to the storage (figure 7.10) 将数据持久化到存储中(图7.10)

Figure 7.10 Hexagonal and functional architectures work best when all references to out-of-process dependencies can be pushed to the edges of business operations. 图7.10 当所有对流程外依赖关系的引用可以被推到业务操作的边缘时,六边形和功能性架构的效果最好。
There are a lot of situations where these stages aren’t as clearcut, though. As we discussed in chapter 6, you might need to query additional data from an out-of-process dependency based on an intermediate result of the decision-making process (figure 7.11). Writing to the out-of-process dependency often depends on that result, too.
不过,有很多情况下,这些阶段并不是那么清晰的。正如我们在第 6 章所讨论的,你可能需要根据决策过程的中间结果从进程外依赖关系中查询额外的数据(图 7.11)。向进程外依赖关系的写入往往也取决于这个结果。

Figure 7.11 A hexagonal architecture doesn’t work as well when you need to refer to out-of-process dependencies in the middle of the business operation. 图 7.11 当你需要在业务操作的中间参考进程外的依赖关系时,六边形的架构就不那么好用了。
As also discussed in the previous chapter, you have three options in such a situation: 前一章也讨论过,在这种情况下你有三种选择:
- Push all external reads and writes to the edges anyway. This approach preserves the read-decide-act structure but concedes performance: the controller will call out-of-process dependencies even when there’s no need for that. 无论如何将所有的外部读写推到边缘。这种方法保留了读-决定-行动的结构,但在性能上有所欠缺:即使没有必要,控制器也会调用进程外依赖。
- Inject the out-of-process dependencies into the domain model and allow the business logic to directly decide when to call those dependencies. 将进程外依赖注入领域模型中,并允许业务逻辑直接决定何时调用这些依赖。
- Split the decision-making process into more granular steps and have the controller act on each of those steps separately. 将决策过程分成更细化的步骤,让控制器分别对每个步骤采取行动。
The challenge is to balance the following three attributes: 挑战在于如何平衡以下三个属性:
- Domain model testability, which is a function of the number and type of collaborators in domain classes 领域模型的可测试性,它是领域类中合作者数量和类型的函数
- Controller simplicity, which depends on the presence of decision-making (branching) points in the controller 控制器的简单性,这取决于控制器中是否存在决策(分支)点
- Performance, as defined by the number of calls to out-of-process dependencies 性能,由调用进程外依赖关系的数量来定义。
Each option only gives you two out of the three attributes (figure 7.12): 每个选项只给你三个属性中的两个(图7.12):
- Pushing all external reads and writes to the edges of a business operation—Preserves controller simplicity and keeps the domain model isolated from out-of-process dependencies (thus allowing it to remain testable) but concedes performance. 将所有的外部读写推到业务操作的边缘-保持控制器的简单性,并使域模型与进程外的依赖关系隔离(从而使其保持可测试性),但在性能上有所损失。
- Injecting out-of-process dependencies into the domain model—Keeps performance and the controller’s simplicity intact but damages domain model testability. 将进程外的依赖注入领域模型-保持性能和控制器的简单性,但破坏了领域模型的可测试性。
- Splitting the decision-making process into more granular steps—Helps with both performance and domain model testability but concedes controller simplicity. You’ll need to introduce decision-making points in the controller in order to manage these granular steps. 将决策过程分割成更细的步骤—有助于提高性能和领域模型的可测试性,但让步于控制器的简单性。你需要在控制器中引入决策点,以管理这些细化的步骤。

Figure 7.12 There’s no single solution that satisfies all three attributes: controller simplicity, domain model testability, and performance. You have to choose two out of the three. 图7.12 没有一个解决方案可以满足所有的三个属性:控制器的简单性、领域模型的可测试性和性能。你必须从三者中选择两个。
In most software projects, performance is important, so the first approach (pushing external reads and writes to the edges of a business operation) is out of the question. The second option (injecting out-of-process dependencies into the domain model) brings most of your code into the overcomplicated quadrant on the types-of-code diagram. This is exactly what we refactored the initial CRM implementation away from. I recommend that you avoid this approach: such code no longer preserves the separation between business logic and communication with out-of-process dependencies and thus becomes much harder to test and maintain.
在大多数软件项目中,性能很重要,所以第一种方法(将外部读写推到业务操作的边缘)是不可能的。第二种方法(将进程外的依赖注入领域模型)将你的大部分代码带入代码类型图上的过度复杂的象限。这正是我们重构最初的CRM实现所要避免的。我建议你避免这种做法:这种代码不再保留业务逻辑与进程外依赖关系的通信之间的分离,因此变得更难测试和维护。
That leaves you with the third option: splitting the decision-making process into smaller steps. With this approach, you will have to make your controllers more complex, which will also push them closer to the overcomplicated quadrant. But there are ways to mitigate this problem. Although you will rarely be able to factor all the complexity out of controllers as we did previously in the sample project, you can keep that complexity manageable.
这就给你留下了第三个选择:将决策过程分割成更小的步骤。采用这种方法,你将不得不使你的控制器更加复杂,这也会使它们更接近于过度复杂的象限。但是有一些方法可以缓解这个问题。虽然你很少能像我们之前在样本项目中那样,把控制器的所有复杂性都考虑进去,但你可以保持这种复杂性的可控性。
7.4.1 Using the CanExecute/Execute pattern
The first way to mitigate the growth of the controllers’ complexity is to use the CanExecute/Execute pattern, which helps avoid leaking of business logic from the domain model to controllers. This pattern is best explained with an example, so let’s expand on our sample project.
缓解控制器复杂性增长的第一个方法是使用CanExecute/Execute模式,它有助于避免业务逻辑从领域模型泄漏到控制器。这个模式最好用一个例子来解释,所以让我们在我们的示例项目上展开。
Let’s say that a user can change their email only until they confirm it. If a user tries to change the email after the confirmation, they should be shown an error message. To accommodate this new requirement, we’ll add a new property to the User class.
比方说,用户只能在确认之前改变他们的电子邮件。如果用户在确认后试图更改电子邮件,他们应该显示一个错误信息。为了适应这个新的要求,我们将在用户类中添加一个新的属性。
Listing 7.7 User with a new property
public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public bool IsEmailConfirmed { get; private set; } // New property
/* ChangeEmail(newEmail, company) method */
}
There are two options for where to put this check. First, you could put it in User’s ChangeEmail method:
public string ChangeEmail(string newEmail, Company company)
{
if (IsEmailConfirmed)
return "Can't change a confirmed email";
/* the rest of the method */
}
Then you could make the controller either return an error or incur all necessary side effects, depending on this method’s output. 然后你可以让控制器要么返回一个错误,要么产生所有必要的副作用,这取决于这个方法的输出。
Listing 7.8 The controller, still stripped of all decision-making
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId); // Prepares the data
User user = UserFactory.Create(userData);
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData); //
string error = user.ChangeEmail(newEmail, company); // Acts on the decision
if (error != null)
return error;
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail); //
return "OK";
}
This implementation keeps the controller free of decision-making, but it does so at the expense of a performance drawback. The Company instance is retrieved from the database unconditionally, even when the email is confirmed and thus can’t be changed. This is an example of pushing all external reads and writes to the edges of a business operation.
这种实现方式使控制器不需要决策,但它是以性能缺陷为代价的。公司实例被无条件地从数据库中检索出来,即使电子邮件被确认,也不能被改变。这是一个将所有外部读写推到业务操作边缘的例子。
NOTE
I don’t consider the new if statement analyzing the error string an increase in complexity because it belongs to the acting phase; it’s not part of the decision-making process. All the decisions are made by the User class, and the controller merely acts on those decisions.
我不认为分析错误字符串的新if语句增加了复杂性,因为它属于行动阶段;它不是决策过程的一部分。所有的决定都是由用户类做出的,而控制器只是对这些决定采取行动。
The second option is to move the check for IsEmailConfirmed from User to the controller.
第二个选择是将IsEmailConfirmed的检查从User转移到控制器。
Listing 7.9 Controller deciding whether to change the user’s email
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
if (user.IsEmailConfirmed) // Decision-making moved here from User.
return "Can't change a confirmed email";
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
return "OK";
}
With this implementation, the performance stays intact: the Company instance is retrieved from the database only after it is certain that the email can be changed. But now the decision-making process is split into two parts: 通过这种实现方式,性能保持不变:只有在确定电子邮件可以被改变之后,才从数据库中检索公司实例。但是现在决策过程被分成两部分:
- Whether to proceed with the change of email (performed by the controller) 是否进行电子邮件的更改(由控制器执行)。
- What to do during that change (performed by User) 在该更改过程中做什么(由用户执行)
Now it’s also possible to change the email without verifying the IsEmailConfirmed flag first, which diminishes the domain model’s encapsulation. Such fragmentation hinders the separation between business logic and orchestration and moves the controller closer to the overcomplicated danger zone.
现在也可以不先验证IsEmailConfirmed标志就改变电子邮件,这就削弱了域模型的封装性。这种碎片化阻碍了业务逻辑和协调之间的分离,使控制器更接近于过度复杂的危险区域。
To prevent this fragmentation, you can introduce a new method in User, CanChangeEmail(), and make its successful execution a precondition for changing an email. The modified version in the following listing follows the CanExecute/Execute pattern.
为了防止这种分裂,你可以在User中引入一个新的方法,CanChangeEmail(),并将其成功执行作为改变电子邮件的前提条件。下面列表中的修改版本遵循CanExecute/Execute模式。
Listing 7.10 Changing an email using the CanExecute/Execute pattern
public string CanChangeEmail()
{
if (IsEmailConfirmed)
return "Can't change a confirmed email";
return null;
}
public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
/* the rest of the method */
}
This approach provides two important benefits: 这种方法提供了两个重要的好处:
- The controller no longer needs to know anything about the process of changing emails. All it needs to do is call the CanChangeEmail() method to see if the operation can be done. Notice that this method can contain multiple validations, all encapsulated away from the controller. 控制器不再需要知道任何关于改变电子邮件的过程。它所需要做的就是调用CanChangeEmail()方法,看看是否可以进行该操作。请注意,这个方法可以包含多个验证,所有这些都被封装在控制器之外。
- The additional precondition in ChangeEmail() guarantees that the email won’t ever be changed without checking for the confirmation first. ChangeEmail()中的额外前提条件保证了在没有检查确认的情况下,电子邮件不会被改变。
This pattern helps you to consolidate all decisions in the domain layer. The controller no longer has an option not to check for the email confirmation, which essentially eliminates the new decision-making point from that controller. Thus, although the controller still contains the if statement calling CanChangeEmail(), you don’t need to test that if statement. Unit testing the precondition in the User class itself is enough.
这种模式可以帮助你在领域层中整合所有的决定。控制器不再有不检查邮件确认的选项,这从本质上消除了该控制器的新决策点。因此,尽管控制器仍然包含调用CanChangeEmail()的if语句,你不需要测试这个if语句。单元测试用户类本身的前提条件就足够了。
NOTE
For simplicity’s sake, I’m using a string to denote an error. In a realworld project, you may want to introduce a custom Result class to indicate the success or failure of an operation.
为了简单起见,我使用一个字符串来表示一个错误。在现实世界的项目中,你可能想引入一个自定义的结果类来表示一个操作的成功或失败。
7.4.2 Using domain events to track changes in the domain model
It’s sometimes hard to deduct what steps led the domain model to the current state. Still, it might be important to know these steps because you need to inform external systems about what exactly has happened in your application. Putting this responsibility on the controllers would make them more complicated. To avoid that, you can track important changes in the domain model and then convert those changes into calls to out-of-process dependencies after the business operation is complete. Domain events help you implement such tracking.
有时很难推断是哪些步骤导致领域模型变成了当前状态。不过,知道这些步骤可能还是很重要的,因为你需要告知外部系统你的应用程序中到底发生了什么。把这个责任放在控制器上会使它们更加复杂。为了避免这种情况,你可以跟踪领域模型中的重要变化,然后在业务操作完成后将这些变化转换成对进程外依赖关系的调用。领域事件可以帮助你实现这种跟踪。
DEFINITION
A domain event describes an event in the application that is meaningful to domain experts. The meaningfulness for domain experts is what differentiates domain events from regular events (such as button clicks). Domain events are often used to inform external applications about important changes that have happened in your system.
领域事件描述了应用程序中对领域专家有意义的事件。对领域专家有意义是领域事件与普通事件(如按钮点击)的区别。领域事件经常被用来通知外部应用程序关于你的系统中发生的重要变化。
Our CRM has a tracking requirement, too: it has to notify external systems about changed user emails by sending messages to the message bus. The current implementation has a flaw in the notification functionality: it sends messages even when the email is not changed, as shown in the following listing.
我们的CRM也有一个跟踪需求:它必须通过向消息总线发送消息来通知外部系统有关用户电子邮件的变化。目前的实现在通知功能上有一个缺陷:即使电子邮件没有改变,它也会发送消息,如下面的列表所示。
Listing 7.11 Sends a notification even when the email has not changed
// User
public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail) // User email may not change.
return;
/* the rest of the method */
}
// Controller
public string ChangeEmail(int userId, string newEmail)
{
/* preparations */
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage( // The controller sends a message anyway.
userId, newEmail);
return "OK";
}
You could resolve this bug by moving the check for email sameness to the controller, but then again, there are issues with the business logic fragmentation. And you can’t put this check to CanChangeEmail() because the application shouldn’t return an error if the new email is the same as the old one.
你可以通过把检查邮件的同一性移到控制器中来解决这个bug,但这样一来,业务逻辑的分割就有问题了。而且你不能把这个检查放到CanChangeEmail()中,因为如果新邮件与旧邮件相同,应用程序不应该返回错误。
Note that this particular check probably doesn’t introduce too much business logic fragmentation, so I personally wouldn’t consider the controller overcomplicated if it contained that check. But you may find yourself in a more difficult situation in which it’s hard to prevent your application from making unnecessary calls to out-of-process dependencies without passing those dependencies to the domain model, thus overcomplicating that domain model. The only way to prevent such overcomplication is the use of domain events.
请注意,这个特殊的检查可能不会引入太多的业务逻辑碎片,所以我个人认为,如果控制器包含这个检查,不会过于复杂。但是你可能会发现自己处在一个更困难的情况下,很难防止你的应用程序对进程外的依赖关系进行不必要的调用,而不把这些依赖关系传递给领域模型,从而使该领域模型过度复杂化。防止这种过度复杂化的唯一方法是使用领域事件。
From an implementation standpoint, a domain event is a class that contains data needed to notify external systems. In our specific example, it is the user’s ID and email: 从实现的角度看,领域事件是一个包含通知外部系统所需数据的类。在我们的具体例子中,它是用户的ID和电子邮件:
public class EmailChangedEvent
{
public int UserId { get; }
public string NewEmail { get; }
}
NOTE
Domain events should always be named in the past tense because they represent things that already happened. Domain events are values—they are immutable and interchangeable.
领域事件应该总是用过去式命名,因为它们代表已经发生的事情。域事件是值—它们是不可改变的和可交换的。
User will have a collection of such events to which it will add a new element when the email changes. This is how its ChangeEmail() method looks after the refactoring.
User将有一个这样的事件集合,当电子邮件发生变化时,它将向其中添加一个新元素。这就是它的 ChangeEmail() 方法在重构后的样子。
Listing 7.12 User adding an event when the email changes
public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
}
Email = newEmail;
Type = newType;
EmailChangedEvents.Add(
new EmailChangedEvent(UserId, newEmail)); // A new event indicates the change of email.
}
The controller then will convert the events into messages on the bus.
Listing 7.13 The controller processing domain events
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
foreach (var ev in user.EmailChangedEvents) // Domain event processing
{
_messageBus.SendEmailChangedMessage(
ev.UserId, ev.NewEmail);
} //
return "OK";
}
Notice that the Company and User instances are still persisted in the database unconditionally: the persistence logic doesn’t depend on domain events. This is due to the difference between changes in the database and messages in the bus.
请注意,公司和用户实例仍然无条件地被持久化在数据库中:持久化逻辑并不依赖于领域事件。这是由于数据库中的变化和数据库中的消息之间的区别。
Assuming that no application has access to the database other than the CRM, communications with that database are not part of the CRM’s observable behavior—they are implementation details. As long as the final state of the database is correct, it doesn’t matter how many calls your application makes to that database. On the other hand, communications with the message bus are part of the application’s observable behavior. In order to maintain the contract with external systems, the CRM should put messages on the bus only when the email changes.
假设除了CRM之外,没有任何应用可以访问数据库,那么与数据库的通信就不是CRM可观察行为的一部分—它们是实现细节。只要数据库的最终状态是正确的,你的应用程序对该数据库进行多少次调用并不重要。另一方面,与消息总线的通信是应用程序的可观察行为的一部分。为了维护与外部系统的契约,CRM应该只在邮件发生变化时才把消息放到总线上。
There are performance implications to persisting data in the database unconditionally, but they are relatively insignificant. The chances that after all the validations the new email is the same as the old one are quite small. The use of an ORM can also help. Most ORMs won’t make a round trip to the database if there are no changes to the object state.
无条件地将数据持久化在数据库中会有性能上的影响,但这些影响相对来说并不明显。在所有的验证之后,新邮件与旧邮件相同的几率是相当小的。使用ORM也会有帮助。如果对象的状态没有变化,大多数ORM都不会到数据库中进行往返。
You can generalize the solution with domain events: extract a DomainEvent base class and introduce a base class for all domain classes, which would contain a collection of such events: List events. You can also write a separate event dispatcher instead of dispatching domain events manually in controllers. Finally, in larger projects, you might need a mechanism for merging domain events before dispatching them. That topic is outside the scope of this book, though. You can read about it in my article “Merging domain events before dispatching” at http://mng.bz/YeVe.
你可以用领域事件来概括这个解决方案:提取一个DomainEvent基类,为所有领域类引入一个基类,它将包含此类事件的集合: 列表事件。你也可以写一个单独的事件调度器,而不是在控制器中手动调度域事件。最后,在更大的项目中,你可能需要一种机制来合并域事件,然后再分派它们。不过,这个话题已经超出了本书的范围。你可以在我的文章 “调度前合并域事件 “中读到它,网址是http://mng.bz/YeVe。
Domain events remove the decision-making responsibility from the controller and put that responsibility into the domain model, thus simplifying unit testing communications with external systems. Instead of verifying the controller itself and using mocks to substitute out-of-process dependencies, you can test the domain event creation directly in unit tests, as shown next.
领域事件将决策责任从控制器中移除,并将该责任放到领域模型中,从而简化了与外部系统的单元测试通信。你可以在单元测试中直接测试域事件的创建,而不是验证控制器本身并使用模拟来替代进程外的依赖关系,如下所示。
Listing 7.14 Testing the creation of a domain event
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
var company = new Company("mycorp.com", 1);
var sut = new User(1, "user@mycorp.com", UserType.Employee, false);
sut.ChangeEmail("new@gmail.com", company);
company.NumberOfEmployees.Should().Be(0);
sut.Email.Should().Be("new@gmail.com"); // Simultaneously asserts the collection size and the element in the collection
sut.Type.Should().Be(UserType.Customer);
sut.EmailChangedEvents.Should().Equal(
new EmailChangedEvent(1, "new@gmail.com"));
}
Of course, you’ll still need to test the controller to make sure it does the orchestration correctly, but doing so requires a much smaller set of tests. That’s the topic of the next chapter.
当然,你仍然需要测试控制器,以确保它能正确地进行协调,但这样做需要一组更小的测试。这就是下一章的主题。
7.5 Conclusion
Notice a theme that has been present throughout this chapter: abstracting away the application of side effects to external systems. You achieve such abstraction by keeping those side effects in memory until the very end of the business operation, so that they can be tested with plain unit tests without involving out-of-process dependencies. Domain events are abstractions on top of upcoming messages in the bus. Changes in domain classes are abstractions on top of upcoming modifications in the database.
请注意本章中的一个主题:抽象出对外部系统的副作用的应用。通过将这些副作用保留在内存中,直到业务操作的最后阶段来实现这种抽象,这样就可以用普通的单元测试来测试,而不涉及进程外的依赖。领域事件是在总线中即将到来的消息之上的抽象。领域类的变化是在数据库中即将进行的修改之上的抽象。
NOTE
It’s easier to test abstractions than the things they abstract.
测试抽象的东西比测试它们所抽象的东西更容易。
Although we were able to successfully contain all the decision-making in the domain model with the help of domain events and the CanExecute/Execute pattern, you won’t be able to always do that. There are situations where business logic fragmentation is inevitable.
尽管我们能够在领域事件和CanExecute/Execute模式的帮助下成功地包含领域模型中的所有决策,但你不可能总是这样做。有些情况下,业务逻辑碎片化是不可避免的
For example, there’s no way to verify email uniqueness outside the controller without introducing out-of-process dependencies in the domain model. Another example is failures in out-of-process dependencies that should alter the course of the business operation. The decision about which way to go can’t reside in the domain layer because it’s not the domain layer that calls those out-of-process dependencies. You will have to put this logic into controllers and then cover it with integration tests. Still, even with the potential fragmentation, there’s a lot of value in separating business logic from orchestration because this separation drastically simplifies the unit testing process.
例如,如果不在域模型中引入进程外的依赖关系,就没有办法在控制器外验证电子邮件的唯一性。另一个例子是进程外依赖关系的失败,应该改变业务操作的进程。关于走哪条路的决定不能停留在域层,因为调用这些进程外依赖关系的不是域层。你将不得不把这个逻辑放到控制器中,然后用集成测试覆盖它。尽管如此,即使有潜在的碎片化,将业务逻辑与协调分离仍有很大的价值,因为这种分离极大地简化了单元测试过程。
Just as you can’t avoid having some business logic in controllers, you will rarely be able to remove all collaborators from domain classes. And that’s fine. One, two, or even three collaborators won’t turn a domain class into overcomplicated code, as long as these collaborators don’t refer to out-of-process dependencies.
就像你无法避免在控制器中使用一些业务逻辑一样,你很少能够从域类中删除所有的协作器。而这也很好。一个、两个、甚至三个协作器都不会把域类变成过于复杂的代码,只要这些协作器不引用进程外的依赖关系。
Don’t use mocks to verify interactions with such collaborators, though. These interactions have nothing to do with the domain model’s observable behavior. Only the very first call, which goes from a controller to a domain class, has an immediate connection to that controller’s goal. All the subsequent calls the domain class makes to its neighbor domain classes within the same operation are implementation details
不过,不要用模拟来验证与这些合作者的交互。这些交互与领域模型的可观察行为毫无关系。只有最开始的调用,即从控制器到领域类的调用,与控制器的目标有直接联系。在同一操作中,域类对其邻近域类的所有后续调用都是实现细节。
Figure 7.13 illustrates this idea. It shows the communications between components in the CRM and their relationship to observable behavior. As you may remember from chapter 5, whether a method is part of the class’s observable behavior depends on whom the client is and what the goals of that client are. To be part of the observable behavior, the method must meet one of the following two criteria:
图7.13说明了这个想法。它显示了CRM中组件之间的通信以及它们与可观察行为的关系。你可能还记得第五章的内容,一个方法是否是类的可观察行为的一部分,取决于客户是谁,以及客户的目标是什么。要成为可观察行为的一部分,该方法必须满足以下两个标准之一:
- Have an immediate connection to one of the client’s goals 与客户的目标之一有直接联系
- Incur a side effect in an out-of-process dependency that is visible to external applications 在进程外的依赖关系中产生一个副作用。

Figure 7.13 A map that shows communications among components in the CRM and the relationship between these communications and observable behavior 图7.13 显示CRM中各组件之间的通信以及这些通信与可观察行为之间关系的地图
The controller’s ChangeEmail() method is part of its observable behavior, and so is the call it makes to the message bus. The first method is the entry point for the external client, thereby meeting the first criterion. The call to the bus sends messages to external applications, thereby meeting the second criterion. You should verify both of these method calls (which is the topic of the next chapter). However, the subsequent call from the controller to User doesn’t have an immediate connection to the goals of the external client. That client doesn’t care how the controller decides to implement the change of email as long as the final state of the system is correct and the call to the message bus is in place. Therefore, you shouldn’t verify calls the controller makes to User when testing that controller’s behavior.
控制器的ChangeEmail()方法是其可观察行为的一部分,而它对消息总线的调用也是。第一个方法是外部客户端的入口,因此符合第一个标准。对总线的调用将消息发送到外部应用程序,从而满足第二条标准。你应该验证这两个方法的调用(这是下一章的主题)。然而,随后从控制器到用户的调用与外部客户端的目标没有直接联系。只要系统的最终状态是正确的,并且对消息总线的调用到位,该客户端并不关心控制器如何决定实现电子邮件的变更。因此,在测试该控制器的行为时,你不应该验证控制器对用户的调用。
When you step one level down the call stack, you get a similar situation. Now it’s the controller who is the client, and the ChangeEmail method in User has an immediate connection to that client’s goal of changing the user email and thus should be tested. But the subsequent calls from User to Company are implementation details from the controller’s point of view. Therefore, the test that covers the ChangeEmail method in User shouldn’t verify what methods User calls on Company. The same line of reasoning applies when you step one more level down and test the two methods in Company from User’s point of view.
当你往下走一级调用栈时,你会得到一个类似的情况。现在,控制器是客户,User中的ChangeEmail方法与客户改变用户邮件的目标有直接联系,因此应该被测试。但是从控制器的角度来看,从User到Company的后续调用是实现细节。因此,涵盖User中ChangeEmail方法的测试不应该验证User在Company上调用了哪些方法。当你再往下走一步,从用户的角度测试公司的两个方法时,同样的推理也适用。
Think of the observable behavior and implementation details as onion layers. Test each layer from the outer layer’s point of view, and disregard how that layer talks to the underlying layers. As you peel these layers one by one, you switch perspective: what previously was an implementation detail now becomes an observable behavior, which you then cover with another set of tests.
把可观察的行为和实现的细节想象成洋葱层。从外层的角度来测试每一层,而不考虑该层如何与底层对话。当你逐一剥开这些层时,你就会转换视角:以前是实现细节,现在变成了可观察行为,然后用另一组测试来覆盖。
Summary
- Code complexity is defined by the number of decision-making points in the code, both explicit (made by the code itself) and implicit (made by the libraries the code uses).
- Domain significance shows how significant the code is for the problem domain of your project. Complex code often has high domain significance and vice versa, but not in 100% of all cases.
- Complex code and code that has domain significance benefit from unit testing the most because the corresponding tests have greater protection against regressions.
- Unit tests that cover code with a large number of collaborators have high maintenance costs. Such tests require a lot of space to bring collaborators to an expected condition and then check their state or interactions with them afterward.
- All production code can be categorized into four types of code by its complexity or domain significance and the number of collaborators:
- – Domain model and algorithms (high complexity or domain significance, few collaborators) provide the best return on unit testing efforts.
- – Trivial code (low complexity and domain significance, few collaborators) isn’t worth testing at all.
- – Controllers (low complexity and domain significance, large number of collaborators) should be tested briefly by integration tests.
- – Overcomplicated code (high complexity or domain significance, large number of collaborators) should be split into controllers and complex code.
- The more important or complex the code is, the fewer collaborators it should have. The Humble Object pattern helps make overcomplicated code testable by extracting business logic out of that code into a separate class. As a result, the remaining code becomes a controller—a thin, humble wrapper around the business logic.
- The hexagonal and functional architectures implement the Humble Object pattern. Hexagonal architecture advocates for the separation of business logic and communications with out-of-process dependencies. Functional architecture separates business logic from communications with all collaborators, not just out-ofprocess ones.
- Think of the business logic and orchestration responsibilities in terms of code depth versus code width. Your code can be either deep (complex or important) or wide (work with many collaborators), but never both.
- Test preconditions if they have a domain significance; don’t test them otherwise.
- There are three important attributes when it comes to separating business logic from orchestration:
- Domain model testability—A function of the number and the type of collaborators in domain classes
- Controller simplicity—Depends on the presence of decision-making points in the controller
- Performance—Defined by the number of calls to out-of-process dependencies
- You can have a maximum of two of these three attributes at any given moment:
- Pushing all external reads and writes to the edges of a business operation—Preserves controller simplicity and keeps the domain model testability, but concedes performance
- Injecting out-of-process dependencies into the domain model—Keeps performance and the controller’s simplicity, but damages domain model testability
- Splitting the decision-making process into more granular steps—Preserves performance and domain model testability, but gives up controller simplicity
- Splitting the decision-making process into more granular steps—Is a trade-off with the best set of pros and cons. You can mitigate the growth of controller complexity using the following two patterns:
- The CanExecute/Execute pattern introduces a CanDo() for each Do() method and makes its successful execution a precondition for Do(). This pattern essentially eliminates the controller’s decision-making because there’s no option not to call CanDo() before Do().
- Domain events help track important changes in the domain model, and then convert those changes to calls to out-of-process dependencies. This pattern removes the tracking responsibility from the controller.
- It’s easier to test abstractions than the things they abstract. Domain events are abstractions on top of upcoming calls to out-of-process dependencies. Changes in domain classes are abstractions on top of upcoming modifications in the data storage.