前言

Github:https://github.com/HealerJean

博客:http://blog.healerjean.com

一、稳定的领域模型

在领域设计建模时,务必不要考虑过多的技术实现细节,以免影响和干扰领域逻辑的设计。在设计时,让我们忘记数据库,忘记网络通信,忘记第三方服务调用,通过端口抽象出领域层需要调用的外部资源接口,即可在一定程度隔离业务与技术的实现,避免两个不同方向的复杂度产生叠加效应。

遵循整洁架构思想,我们希望最终获得的领域模型并不依赖于任何外部设备、资源和框架。简而言之,领域层的设计目标就是要达到逻辑层的自给自足,唯有不依赖于外物的领域模型才是最纯粹、最独立、最稳定的模型。”

一个稳定的领域模型也是最容易执行单元测试的模型

1、菱形对称架构与测试金字塔

菱形对称架构的分层决定了它们不同的职责与设计的粒度。层次、职责和粒度的差异,恰好与测试金字塔形成一一对应的关系

image-20240920150141815

2、测试形成的精炼文档

如果测试编写得体,测试代码也可以认为是一份精炼文档,且这样的文档还具有和实现与时俱进的演进能力,形成一种活文档

测试方法体应遵循 Given - When - Then 模式。该模式清晰地描述了测试的准备、期待的行为和相关的验收条件。

Given:为要测试的方法提供准备,包括创建被测试对象,为调用方法准备输入参数实参等。

When:调用被测试的方法,遵循单一职责原则,在一个测试方法的 When 部分,应该只有一条语句对被测方法进行调用。

Then:对被测方法调用后的结果进行预期验证。

public class AccountTest {
   private AccountId srcAccountId;
   private AccountId targetAccountId;
   @before
     void setup(){
         srcAccountId = AccountId.of("123456");        //用于演示
         target AccountId = AccountId.of("654321");    //用于演示
   }
   @Test
   void should_transfer_from_src_account_to_target_account_given_correct_transfer_amount() {
      
     // given
      Money balanceOfSrc = new Money(100_000L, Currency.RMB);
      SourceAccount src = new Account(srcAccountId, balanceOfSrc);
      Money balanceOfDes = new Money(0L, Currency.RMB);
      TargetAccount target = new Account(targetAccountId, balanceOfDes);
      Money trasferAmount = new Money(10_000L, Currency.RMB);
     
     // when
      src.transferTo(target, transferAmount);
     
     // then
      assertThat(src.getBalance()).isEqualTo(MoCurrency.RMB));
      assertThat(target.getBalance()).isEqualTo(Money.of(10_000L, Currency.RMB));
   }
}

三、领域建模过程

image-20240920154042670

公司雇员有三种类型。

一种雇员是钟点工,系统会按照雇员记录中每小时报酬字段的值对他们进行支付。他们每天会提交工作时间卡,其中记录了日期以及工作小时数。如果他们每天工作超过8小时,超过部分会按照正常报酬的1.5倍进行支付。支付日期为每周五。

月薪制的雇员以月薪进行支付。每个月的最后一个工作日对他们进行支付。在雇员记录中有月薪字段。

销售人员会根据他们的销售情况支付一定数量的酬金(Commssion)。他们会提交销售凭条,其中记录了销售的日期和数量。在他们的雇员记录中有一个酬金报酬字段。每隔一周的周五对他们进行支付。

1、任务解析

2)业务服务图

image-20240920154332346

3)服务契约

服务编号:S0006

服务名:支付薪资

服务描述:

​ 作为 财务人员(Accountant)

​ 我想要系统按期自动 支付薪资(Salary)

​ 以便提高财务人员的工作效率,及时发放薪资

触发事件:

​ 每天凌晨0:00自动触发

基本流程:

​ 1、确定 是否支付日( PayDay)

​ 2、获取支付日对应类型的 雇员(Employee)名单

​ 3、计算 薪资,生成雇员的 工资条(Payroll)

​ 3.1 若为 钟点工雇员(HourlyEmployee),根据 工作时间卡(TimeCard)与时薪计算薪资

​ 3.2 若为 月薪雇员(SalariedEmployee),根据 出勤记录 (Attendance)计算薪资

​ 3.3 若为 销售人员(CommissionedEmployee),根据 销售凭条(Sale Receipt)计算薪资

​ 4.向雇员的 银行账户(SavingAccount) 发起 转账支付薪资

​ 5.通过 邮件(Email) 通知 薪资已发放,同时 发送 工资条给员工

替换流程:

​ 1、如果不是支付日,直接退出

​ 4、如果薪资支付失败,给出失败原因,并发送邮件给财务人员

验收标准:

​ 1、钟点工雇员的支付日为每星期五

​ 2、如果钟点工雇员未提交工作时间卡,视为未工作

​ 3、工作时间卡的工作时间最低不少于1小时,最高不高于12小时

​ 4、每天工作超过8小时,超过部分按照正常报酬的1.5倍进行结算

​ 5、月薪雇员的支付日为每个月最后一个工作日

​ 6、若月薪雇员的出勤记录包含旷工,将按照月薪计算出来的日薪进行扣除

​ 7、若月薪雇员的出勤记录包含迟到、早退,将扣除日薪的20%

​ 8、销售人员的支付日为每隔一星期的星期五

​ 9、若销售人员未提交销售凭条,酬金报酬为0

​ 10、会为符合支付条件的员工生成工资条

​ 11、支付成功后,员工工资条的状态会更改为已支付

​ 12、员工收到薪资发放的通知(Notification)

3)任务分解

针对支付薪资场景,任务分解如下:

  • 确定是否支付日期
    • 确定是否为周五
    • 确定是否为月末工作日
      • 获取当月的假期信息
      • 确定当月的最后一个工作日
    • 确定是否为间隔一周周五
      • 获取上一次销售人员的支付日期
      • 确定是否间隔了一周
  • 计算雇员薪资
    • 计算钟点工薪资
      • 获取钟点工雇员与工作时间卡
      • 根据雇员日薪计算薪资
    • 计算月薪雇员薪资
      • 获取月薪雇员与考勤记录
      • 对月薪雇员计算月薪
    • 计算销售人员薪资
      • 获取销售雇员与销售凭条
      • 根据酬金规则计算薪资
  • 支付
    • 向满足条件的雇员账户发起转账
    • 生成支付凭条

2、领域分析建模

在获得了目标系统的业务服务后,需求分析人员需要进一步细化业务服务,编写业务服务规约。如下为支付薪资的业务服务规约。

b、建模

名词建模:业务服务规约添加下划线的内容即我们识别出来的名词,检查这些名词是否符合统一语言的要求,即可快速映射为下图

image-20240920155745338

动词建模:业务服务规约添加斜线的内容即我们识别出来的动词。逐个判断它们对应的领域行为是否需要产生过程数据。识别时,一定要从管理、法律或财务角度判断过程数据的必要性。

例如,“生成雇员工资条”动作的目标数据是工资条,无须记录在某时某刻生成了工资条,因为管理人员并不关心工资条是什么时候生成的,只要工资条存在,就不会产生审计问题。向雇员的银行账户发起转账,支付薪资”动作的目标数据是薪资,但在发起转账时,必须记录何时完成对薪资的支付,支付金额是多少,否则,若雇员没有收到薪资,就可能出现财务纠纷,于是识别出支付记录(Payment),它是支付行为的过程数据。

归纳抽象:通过名词和动词识别了领域模型之后,需要对这些概念进行归纳和抽象。注意,钟点工 (HourlyEmployee)、月薪雇员(SalariedEmployee)和销售人员(CommissionedEmployee)虽然在类型上都是雇员(Employee),但由于它们各有自身的业务含义,不可在领域分析模型中通过雇员对它们进行抽象,否则可能会漏掉重要的领域概念

确定关系:一旦明确了领域概念,就可进一步确定它们的关系,并检查这些关系是否隐含了领域概念。确定关系时,若能显而易见地确定关系数量,就标记出来,如钟点工 ( HourlyEmployee ) 与工作时间卡 ( TimeCard ),就是明显的一对多关系。最终,快速建模法获得的领域分析模型如图所示。

image-20240920163033278

c、分析

领域分析模型要受到限界上下文的约束。薪资管理系统分为员工上下文和薪资上下文,通过识别领域概念与限界上下文知识语境的关系,可以获得图所示的领域分析模型

员工上下文中的员工 Employee 与薪资上下文中的钟点工 HourlyEmployee、月薪雇员 SalariedEmployee 和销售人员CommissionedEmployee 充分体现了领域概念的知识语境,显然,员工上下文并不关心各种雇员类型的薪资计算和支付,而薪资上下文也不需要了解员工的基本信息

image-20240920163611526

3、领域设计建模

薪资管理系统的领域分析模型应由领域专家作为主导开展分析建模,获得的领域分析模型是纯业务的概念抽象,这些概念抽象实际上就是设计类模型的基础。接下来,需要由开发团队引入领域驱动设计要素进行设计建模,获得聚合。

1)聚合设计

a、实体:

1、3 种类型的雇员类都是实体类

2、工资条 Payroll

3、支付记录 Payment

4、销售凭条 SalesReceipt 同一个销售人员可能提交值相同的不同销售凭条,需要引入身份标识来区分

5、

b、值对象:

1、出勤记录 Attendance:对每个雇员而言,出勤记录的值相同

2、作时间卡 TimeCard :相等性可以通过值决定(它的值包含员工ID)

3、财务 Accountant 是雇员的角色

c、领域服务:

1、支付日 PayDay :职责是判断当前日期是否支付日,本质上是一个领域服务

image-20250221155454302

b、确定聚合

image-20250221160345597

2)服务驱动设计

a、确定支付日期的序列图

image-20250221160657375

b、支付业务序列图

image-20250221161222544

PaymentAppService.pay() {
    PaymentService.pay() {
        PayDayService.isPayday(today) {
            Calendar.isFriday(today);
            WorkdayService.isLastWorkday(today) {
                HolidayRepository.ofMonth(month);
                Calendar.isLastWorkday(holidays);
            }        
            WorkdayService.isIntervalFriday(today) {
                PaymentRepository.lastPayday(today);
                Calendar.isFriday(today);
            }
        }
        EmployeeRepository.allOf(employeeType);
        PayrollCalculator.calculate(employees) {
            HourlyEmployeePayrollCalculator.calculate() {
                HourlyEmployeeRepository.all();
                while (employee -> List<HourlyEmployee>) {
                    employee.payroll(PayPeriod);
                }
            }
            SalariedEmployeePayrollCalculator.calculate() {
                SalariedEmployeeRepository.all();
                while (employee -> List<SalariedEmployee>) {
                    employee.payroll();
                }
            }
            CommissionedEmployeePayrollCalculator.calculate() {
                CommissionedEmployeeRepository.all();
                while (employee -> List<CommissionedEmployee>) {
                    employee.payroll(payPeriod);
                }
            }
        }
        PayingPayrollService.execute(employees) {
            TransferClient.transfer(account);
            PaymentRepository.add(payment);
        }
    }
}

3)领域实现建模

获得了与支付薪资有关的领域设计模型类图和序列图脚本后,领域实现建模就可以从业务服务的验收标准开始,编写测试用例,按照测试驱动开发的节奏建立由测试代码和产品代码组成的领域模型

a、编写测试

测试驱动开发的方向是由内至外的,可以先选择业务服务任务树内部由聚合承担的原子任务,例如选择原子任务“根据雇员日薪计算薪资”。参考业务服务规约的验收标准,为其识别如下测试用例

1、计算正常工作时长的钟点工薪资;

2、计算加班工作时长的钟点工薪资;

3、计算没有工作时间卡的钟点工薪资。

目前还未实现这些测试用例。选择“计算正常工作时长的钟点工薪资”测试用例作为新加功能,为它编写一个刚好失败的测试。由于当前任务是一个原子任务,且HourlyEmployee 聚合拥有计算薪资的信息,履行当前任务对应职责的角色构造型就是 HourlyEmployee 聚合。根据单元测试的命名规范,创建 HourlyEmployeeTest 测试类,编写测试

public class HourlyEmployeeTest {
   
  @Test
   public void should_calculate_payroll_by_work_hours_in_a_week() {
      //given
      TimeCard timeCard1 = new TimeCard(LocalDate.of(2019, 9, 2), 8);
      TimeCard timeCard2 = new TimeCard(LocalDate.of(2019, 9, 3), 8);
      TimeCard timeCard3 = new TimeCard(LocalDate.of(2019, 9, 4), 8);
      TimeCard timeCard4 = new TimeCard(LocalDate.of(2019, 9, 5), 8);
      TimeCard timeCard5 = new TimeCard(LocalDate.of(2019, 9, 6), 8);
      List<TimeCard> timeCards = new ArrayList<>();
      timeCards.add(timeCard1);
      timeCards.add(timeCard2);
      timeCards.add(timeCard3);
      timeCards.add(timeCard4);
      timeCards.add(timeCard5);
      HourlyEmployee hourlyEmployee = new HourlyEmployee(timeCards, 
                                                         Money.of(10000, Currency.RMB));
      //when
      Payroll payroll = hourlyEmployee.payroll();
     
     //then
      assertThat(payroll).isNotNull();
      assertThat(payroll.beginDate()).isEqualTo(LocalDate.of(2019, 9, 2));
      assertThat(payroll.endDate()).isEqualTo(LocalDate.of(2019, 9, 6));
      assertThat(payroll.amount()).isEqualTo(Money.of(400000, Currency.RMB));
   }
}

b、快速实现

实现 payroll() 方法时,应仅提供满足当前测试用例预期的快速实现。以当前测试方法为例,要计算钟点工的薪资,除了需要它提供的工作时间卡,还需要钟点工的时薪,至于 HourlyEmployee 的其他属性,暂时可不用考虑。当前测试方法没有要求验证工作时间卡的有效性,在实现时,亦不必验证传入的工作时间卡是否符合要求,只需确保为测试方法准备的数据是正确的即可。既然当前测试方法只针对正常工作时长计算薪资,就无须考虑加班的情况

public class HourlyEmployee {
  
   private List<TimeCard> timeCards;
   private Money salaryOfHour;
  
   public HourlyEmployee(List<TimeCard> timeCards, Money salaryOfHour) {
      this.timeCards = timeCards;
      this.salaryOfHour = salaryOfHour;
   }
  
   public Payroll payroll() {
   int totalHours = timeCards.stream()
          .map(tc -> tc.workHours())
          .reduce(0, (hours, total) -> hours + total);
   Collections.sort(timeCards);
   LocalDate beginDate = timeCards.get(0).workDay();
   LocalDate endDate = timeCards.get(timeCards.size() - 1).workDay();
   Period settlementPeriod = new Period(beginDate, endDate);
   return new Payroll(settlementPeriod.beginDate, settlementPeriod.endDate,
                      salaryOfHour.multiply(totalHours));
	}
 
}



private class Period {
   private LocalDate beginDate;
   private LocalDate endDate;
   Period(LocalDate beginDate, LocalDate endDate) {
      this.beginDate = beginDate;
      this.endDate = endDate;
   }
}

public class Money {
   private final long value;
   private final Currency currency;
   public static Money of(long value, Currency currency) {
      return new Money(value, currency);
   }
   private Money(long value, Currency currency) {
      this.value = value;
      this.currency = currency;
   }
   public Money multiply(int factor) {
      return new Money(value * factor, currency);
   }
  
  // 实现 `Money`时,还重载了`equals()` 和 `hashcode()` 方法,这是遵循领域驱动设计值对象的要求提供的,不能算作过度设计     
   @Override
   public boolean equals(Object o) {
      if (this == o) return true;
      if (o == null || getClass() != o.getClass()) return false;
      Money money = (Money) o;
      return value == money.value &&
            currency == money.currency;
   }
   @Override
   public int hashCode() {
      return Objects.hash(value, currency);
   }
}

c、简单设计

遵循简单设计原则,可以防止我们做出过度设计。例如,实现“计算正常工作时长的钟点工薪资”测试用例时,我们为“计算加班工作时长的钟点工薪资”测试用例编写测试,实现产品代码。由于需提供超过8小时的工作时间卡,而原有方法采用了固定的8小时正常工作时间,为了测试代码的复用,可提取 createTimeCards() 方法的参数,允许向其传入不同的工作时长。新编写的测试如下所示:

Test
public void should_calculate_payroll_by_work_hours_with_overtime_in_a_week() {
   //given
   List timeCards = createTimeCards(9, 7, 10, 10, 8);
   Money salaryOfHour = Money.of(10000, Currency.RMB);
   HourlyEmployee hourlyEmployee = new HourlyEmployee(timeCards, salaryOfHour);
  
  //when
   Payroll payroll = hourlyEmployee.payroll();
   
  //then
   assertThat(payroll).isNotNull();
   assertThat(payroll.beginDate()).isEqualTo(LocalDate.of(2019, 9, 2));
   assertThat(payroll.endDate()).isEqualTo(LocalDate.of(2019, 9, 6));
   assertThat(payroll.amount()).isEqualTo(Money.of(465000, Currency.RMB));
}
public Payroll payroll() {
   int regularHours = timeCards.stream()
          .map(tc -> tc.workHours() > 8 ? 8 : tc.workHours())
          .reduce(0, (hours, total) -> hours + total);
   int overtimeHours = timeCards.stream()
          .filter(tc -> tc.workHours() > 8)
          .map(tc -> tc.workHours() - 8)
          .reduce(0, (hours, total) -> hours + total);
   Money regularSalary = salaryOfHour.multiply(regularHours);
   
  // 修改了multiply()方法的定义,支持double类型
   Money overtimeSalary = salaryOfHour.multiply(1.5).multiply(overtimeHours);
   Money totalSalary = regularSalary.add(overtimeSalary);
   return new Payroll(
          settlementPeriod().beginDate,
          settlementPeriod().endDate,
          totalSalary);


  private Period settlementPeriod() {
   Collections.sort(timeCards);
   LocalDate beginDate = timeCards.get(0).workDay();
   LocalDate endDate = timeCards.get(timeCards.size() - 1).workDay();
   return new Period(beginDate, endDate);
}


}

按照简单设计原则尝试消除重复,提高代码可读性。首先,对代码作微量调整。阅读实现代码对 filtermap 函数的调用,发现函数接收的 Lambda 表达式操作的数据皆为 TimeCard 类所拥有。遵循“信息专家模式”,做到让对象之间通过行为进行协作,避免协作对象成为数据提供者,需将表达式提取为方法,然后将它们转移到 TimeCard类:”

public class TimeCard implements Comparable {
   private static final int MAXIMUM_REGULAR_HOURS = 8;
   private LocalDate workDay;
   private int workHours;
   
  public TimeCard(LocalDate workDay, int workHours) {
      this.workDay = workDay;
      this.workHours = workHours;
   }
   public int workHours() {
      return this.workHours;
   }
   public LocalDate workDay() {
      return this.workDay;
   }
   public boolean isOvertime() {
      return workHours() > MAXIMUM_REGULAR_HOURS;
   }
   public int getOvertimeWorkHours() {
      return workHours() - MAXIMUM_REGULAR_HOURS;
   }
   public int getRegularWorkHours() {
      return isOvertime() ? MAXIMUM_REGULAR_HOURS : workHours();
   }
}

这一重构说明,只要时刻注意对象之间正确的协作模式,就能在一定程度避免贫血模型。不用刻意追求为领域对象分配领域行为,通过识别代码坏味道,遵循面向对象设计原则就能逐步改进代码。重构后的 payroll() 方法实现为

public Payroll payroll() {
   int regularHours = timeCards.stream()
          .map(TimeCard::getRegularWorkHours)
          .reduce(0, (hours, total) -> hours + total);
   int overtimeHours = timeCards.stream()
          .filter(TimeCard::isOvertime)
          .map(TimeCard::getOvertimeWorkHours)
          .reduce(0, (hours, total) -> hours + total);
   Money regularSalary = salaryOfHour.multiply(regularHours);
   Money overtimeSalary = salaryOfHour.multiply(OVERTIME_FACTOR).multiply(overtimeHours);
   Money totalSalary = regularSalary.add(overtimeSalary);
   return new Payroll(
          settlementPeriod().beginDate,
          settlementPeriod().endDate,
          totalSalary);
}

目前的方法暴露了太多细节,缺乏足够的层次,无法清晰表达方法的执行步骤:先计算正常工作小时数的薪资,再计算加班小时数的薪资,即可得到该钟点工最终要发放的薪资。仍然祭出重构手法,一个简单的提取方法就能达到目的。提取出来的方法既隐藏了细节,又使得主方法清晰地体现了业务步骤

public Payroll payroll() {
   Money regularSalary = calculateRegularSalary();
   Money overtimeSalary = calculateOvertimeSalary();
   Money totalSalary = regularSalary.add(overtimeSalary);
   
  return new Payroll(
         settlementPeriod().beginDate,
         settlementPeriod().endDate,
         totalSalary);
}

d、场景丰富

在考虑该测试用例的测试方法编写时,发现一个问题:如何获得薪资的结算周期?之前的实现通过提交的工作时间卡来获得结算周期,如果钟点工根本没有提交工作时间卡,意味着该钟点工的薪资为 0 ,但并不等于没有薪资结算周期。

事实上,如果提交的工作时间卡存在缺失,也会导致获取薪资结算周期出错。以此而论,即可发现确定薪资结算周期的职责不应该由 HourlyEmployee 聚合承担,它也不具备该知识。然而,payroll() 方法返回的 Payroll 对象又需要结算周期,该对象属于聚合的未知数据,应由外部传入,以此来保证聚合的自给自足,无须访问任何外部资源。因此,在编写新测试之前,还需要先修改已有代码:

public Payroll payroll(Period settlementPeriod) {
   Money regularSalary = calculateRegularSalary(settlementPeriod);
   Money overtimeSalary = calculateOvertimeSalary(settlementPeriod);
   Money totalSalary = regularSalary.add(overtimeSalary);
   return new Payroll(
          settlementPeriod.beginDate(),
          settlementPeriod.endDate(),
          totalSalary);
}

ContactAuthor