DDD + TypeScript之领域实体使用案例

19-05-31 banq
                   

实体是我们应该首先放入业务逻辑的自然场所。在本文中,我们将讨论领域驱动设计中实体的角色和生命周期。

一般公司转向领域驱动设计的最大原因是因为他们的业务具有必要的复杂性。为了管理业务逻辑复杂性,方法是使用面向对象的编程概念来模拟对象之间的复杂行为; 复制特定领域中应该发生的事情,什么时可能发生,或可能不发生。

领域驱动引入了下面一些元素:

让我们来谈谈另一个主要元素:实体。

实体在DDD中的作用

实体几乎是域建模的基础:

1. 它是第一个放置业务逻辑的地方(如果有意义的话)

当我们想表达一个特定的模型:

  • 可以做什么?
  • 什么时候可以做到?
  • 什么条件决定什么时候可以做那件事?

我们的目标是将该逻辑放置在最接近其所属模型的位置。例如:在找工作的用例,雇主可以在申请工作时为申请人留下一些问题,这样我们可以执行一些逻辑规则。

规则#1:您无法向已有申请人的职位添加问题。

规则#2:您不能添加超过作业的最大问题数量。

简单实现的例子:

class Job extends Entity<IJobProps> {
  // ... constructor
  // ... private factory method

  get questions (): QuestionsCollection {
    return this.props.questions;
  }

  public hasApplicants (): boolean {
    return this.props.applicants.length !== 0;
  }

  public addQuestion (question: Question) {
    if (this.hasApplicants()) {
      throw new Error("Can't add a question when there are already applicants to this job.")
    }
    
    if (this.props.questions.length === MAX_QUESTIONS_PER_JOB) {
      throw new Error("This job already has the max amount of questions.")
    }

    this.props.questions.push(question);
  }
}

但是有时,将某些域逻辑置于实体内部并不自然,也没有意义。例如,如果我们使用Customer实体和Movie实体为看电影的应用程序建模时,我们在哪里放置purchaseMovie()方法?一个Customer 可以买票看电影,但Customer实体不需要知道Movies电影的任何事情,这时候,需要把逻辑放入领域服务之中。

强制执行模型不变量

使用DDD构建应用程序就像为您的问题域创建特定于域的语言。为此,我们需要确保只公开对领域有意义且有效的操作。我们还确保满足类不变量。对象创建的验证逻辑通常委托给值对象,但可能发生的事情(以及何时发生)取决于实体。

我在领域建模中犯下的最早错误之一就是为所有变量都暴露了getter和setter。因此,让我们明确指出这不是最好的事情。

不要为所有内部变量添加getter和setter。

之所以不好是因为我们需要控制对象的变化方式(变化方式导致内部变量值的变化,也就是状态变化),我们从不希望我们的对象最终处于无效状态。比如:

class Job extends Entity<IJobProps> {
  // ... constructor
  // ... private factory method

  get questions (): QuestionsCollection {
    return this.props.questions;
  }

  public addQuestion (question: Question) {
    // ...
    if (this.props.questions.length === MAX_QUESTIONS_PER_JOB) {
      throw new Error("This job already has the max amount of questions.")
    }

    this.props.questions.push(question);
  }
}

你注意到没有为questions数组定义一个setter方法吗?我们的领域逻辑是指定某人不应该添加超过每个工作的最大问题questions的数量。如果我们有一个问题questions的setter方法,那么就无法阻止某人通过以下方式完全绕过域逻辑:

job.questions = [{}, {}, {}, {}, {}, ...] // question objects

这就是封装:面向对象编程的4个原则之一。封装是数据完整性的行为; 这在领域建模中尤为重要

身份标识和查询

实体与值对象不同,主要是因为实体具有标识而值对象不具有标识。

实体:User,Job,Organization,Message,Conversation。

值对象:Name,MessageText,JobTitle,ConversationName。

通常,一个实体将是引用其他值对象和实体的模型。User实体:

interface IUserProps {
  name: Username;
  email: Email;
  active: boolean;
}

class User extends Entity<IUserProps> {
  get name (): Username {
    return this.props.name;
  }

  get email (): Email {
    return this.props.email;
  }

  private constructor (props: IUserProps, id?: UniqueEntityId) {
    super(props, id);
  }
  
  public isActive (): boolean {
    return this.props.active;
  }

  public static createUser (props: IUserProps, id?: UniqueEntityId) : Result<User> {
    const userPropsResult: Result = Guard.againstNullOrUndefined([
      { propName: 'name', value: props.name },
      { propName: 'email', value: props.email },
      { propName: 'active', value: props.active }
    ]);

    if (userPropsResult.isSuccess) {
      return Result.ok<User>(new User(props, id))
    } else {
      return Result.fail<User>(userPropsResult.error);
    }
  }
}

我使用UUID而不是自动递增的ID来创建实体。请参阅此文章了解原因。

实体生命周期

1.创建

要创建实体,就像值对象一样,我们使用工厂模式。什么是工厂方法模式?如下:

class User {
  // ...

  private constructor (props: IUserProps, id?: UniqueEntityId) {
    super(props, id);
  }

  public static createUser (props: IUserProps, id?: UniqueEntityId) : Result<User> {
    const userPropsResult: Result = Guard.againstNullOrUndefined([
      { propName: 'name', value: props.name },
      { propName: 'email', value: props.email },
      { propName: 'active', value: props.active }
    ]);

    if (userPropsResult.isSuccess) {
      return Result.ok<User>(new User(props, id))
    } else {
      return Result.fail<User>(userPropsResult.error);
    }
  }
}

createUser方法是一个处理实体创建User的Factory方法,请注意,我们不能使用new关键字并执行:

const user: User = new User(); // <= constructor is private

再次,封装和数据完整性。我们可以控制Users实例是如何进入我们领域层代码的执行过程。

请注意,您永远不应完全复制其他人的实体或值对象类。对于对您的域来说很重要的东西(这基本上就是家庭的珠宝),你自己也值得直接创建一个实体基类供自己使用。

import { UniqueEntityID } from './types';

const isEntity = (v: any): v is Entity<any> => {
  return v instanceof Entity;
};

export abstract class Entity<T> {
  protected readonly _id: UniqueEntityID;
  protected props: T;

  // Take note of this particular nuance here:
  // Why is "id" optional?
  constructor (props: T, id?: UniqueEntityID) {
    this._id = id ? id : new UniqueEntityID();
    this.props = props;
  }

  // Entities are compared based on their referential
  // equality.
  public equals (object?: Entity<T>) : boolean {

    if (object == null || object == undefined) {
      return false;
    }

    if (this === object) {
      return true;
    }

    if (!isEntity(object)) {
      return false;
    }

    return this._id.equals(object._id);
  }
}

以下是关于这个实体基类Entity<T>的重要注意事项:

  1. Entity<T>是一个抽象类。这意味着我们无法直接实例化它。但是,我们可以将其子类化。这是一个合乎逻辑的设计决策。如果实体具有特定类型,则实体才有意义存在,如Car extends Entity<ICarProps>。
  2. id标识只能读取readonly。因此,一旦实例化它就不应该被更改。这也是一个非常合理的设计决定。
  3. 我们使用的equals(object?: Entity<T>)方法来确定一个实体指称等同于另一个实体。如果引用相等不能确定它们是相同的,我们id将该实体与我们比较的实体进行比较。
  4. 该类的属性存储在this.props。原因是因为我们希望将决策留给子类,应该定义属性getter和setter。

这里要注意的最有趣的设计决策是id字段是可选的,不是final的。为什么?当知道id(因为我们已经创建它)时,我们可以传入它,当我们不知道id(因为我们尚未创建它)时,我们创建一个新的(32位UUID)。这解决实体生命周期中的Creation和Reconstitution事件。

2. 存储

在内存中创建实体后,我们需要一种方法将其存储到数据库中。这是在仓储Repository和Mapper的帮助下完成的。仓储是用于保存数据的任何类型的持久化技术,比如关系数据库,NoSQL数据库,JSON文件,文本文件。Mapper是一个简单的域对象来将其保存在数据库中的所需要的格式,反之亦然映射(成Domain对象)的文件。

这是User使用Sequelize ORM :

interface IUserRepo {
  exists (userId: string): Promise<boolean>;
  searchUsersByEmail(email: string): Promise<UsersCollection>;
  getUsers (config: IUserSearchConfig): Promise<UsersCollection>;
  getUsersByRole (config: IUserSearchConfig, role: Role): Promise<UsersCollection>;
  getUser(userId: string): Promise<any>;
  save(user: User): Promise<User>;
}

export class SequelizeUserRepo implements IUserRepo {
  private sequelizeModels: any;

  constructor (sequelizeModels: any) {
    this.sequelizeModels = sequelizeModels;
  }

  exists (userId: string): Promise<boolean> {
    // implement specific algorithm using sequelize orm
  }

  searchUsersByEmail(email: string): Promise<UsersCollection> {
    // implement specific algorithm using sequelize orm
  }

  getUsers (config: IUserSearchConfig): Promise<UsersCollection> {
    // implement specific algorithm using sequelize orm
  }

  getUsersByRole (
    config: IUserSearchConfig, 
    role: Role
  ): Promise<UsersCollection> {
    // implement specific algorithm using sequelize orm
  }

  getUser(userId: string): Promise<any> {
    // implement specific algorithm using sequelize orm
  }

  save(user: User): Promise<User> {
    // implement specific algorithm using sequelize orm
  }
}

假设我们想要实现getUsers方法:使用Sequelize特定语法检索所有用户,然后将这些Active Records映射到User域对象。

import { UserMap } from '../mappers'
export class SequelizeUserRepo implements IUserRepo {
  private sequelizeModels: any;

  // ...
  getUsers (config: IUserSearchConfig): Promise<UsersCollection> {
    const UserModel = this.sequelizeModels.BaseUser;
    const queryObject = this.createQueryObject(config);
    const users: any[] = await UserModel.findAll(queryObject);
    return users.map((u) => UserMap.toDomain(u))
  }
}

以下是Mapper的外观:

export class UserMap extends Mapper<User> {
  public static toDTO (raw: User): UserDTO {
    id: user.id.toString(),
    userName: user.name.value,
    userEmail: user.email.value
  }

  public static toPersistence (user: User): any {
    return {
      user_id: user.id.toString(),
      user_name: user.name.value,
      user_email: user.email.value,
      is_active: user.isActive()
    }
  }

  public static toDomain (raw: any): User {
    const nameOrResult = UserName.create
    const emailOrResult = UserEmail.create(raw.user_email);

    return User.create({
      name: UserName.create(raw.user_name).getValue(),
      email: UserPassword.create(raw.user_password).getValue(),
      active: raw.is_active,
    }, new UniqueEntityID(raw.user_id)).getValue()
  }
}

这是单一责任原则的一个很好的例子。

3. 重建

在我们创建了一个实体并将其持久化到数据库之后,我们将在某个时刻将其拉出来并将其用于修改等操作。同样,这是一个由存储库Repository和映射器Mapper可以轻松维护的工作。

结论

还有更多的域对象,如聚合根和域事件,但您可以开始使用实体和值对象为您的很多领域进行建模。

                   

2