🐱

[DDD] 戦術的設計パターン Part 1 ドメインレイヤー

2024/02/14に公開

はじめに

レバテック開発部テクニカルイネイブリンググループ所属の赤尾と申します。
普段はソフトウェアアーキテクチャーをメイン領域に、チームを横断した技術支援や開発サポートを担当しております。
今回私は、イネイブリンググループの活動の一環として、戦術的DDDの社内勉強会を開催することにしました。
本記事では、勉強会で使用した資料を簡潔にまとめてご紹介いたします。

以下のセクションに分かれております。

  1. ドメインレイヤー (本記事)
  2. アプリケーションレイヤー
  3. プレゼンテーション / インフラストラクチャー レイヤー

※ なお、 DDD における各種 パターン名 の意味については説明しておりません。

(元資料)

本記事は短縮版となります。
細かい実装意図の説明などは、 元記事 をご参照ください。

言語 ・ フレームワーク

TypeScript ・ NestJS

採用アーキテクチャー

オニオンアーキテクチャー

リポジトリ

https://github.com/minericefield/ddd-onion-lit

テーマ

ある組織が、社内で利用するタスク管理アプリケーションを作成することになりました
(そもそもこのテーマが DDD として無理がありますが🙏)。

ユースケース図っぽいもの

use case

ユーザーはタスクに対していくつかの基本的な操作ができます。
かなり変ですが、タスクの進行ステータスの変更や、期日の設定などは設けておりません。
サンプルコードとしてそこまで学びになるところが無さそうだったので、あえて省略しました。

ドメインモデル図っぽいもの

domain model

各エンティティの振る舞いや多重度、そして保持すべき不変条件を記載しております。

ディレクトリ構成

早速ドメイン層から順に実装をしていきたいところですが、ざっくりとしたアーキテクチャーのイメージを持つためにディレクトリ構成だけ先に確認しておきます。
最終的には以下のような構成が完成しました。

src
 ├── application
 │ ├── auth
 │ ├── shared
 │ ├── task
 │ └── user
 ├── domain
 │ ├── shared
 │ ├── task
 │ └── user
 ├── infrastructure
 │ ├── in-memory
 │ ├── mysql
 │ └── uuid
 └── presentation
   ├── http
   └── nest-commander

オニオンアーキテクチャーを意識した構成になっております。

今回は IDDD のモジュール構成を参考にしました。
com.saasovation.agilepm.domain.model.product

com.saasovation(組織) や agilepm(境界づけられたコンテキスト) の概念は登場しておりません。
強いていうなら今回のプロダクトそのものが、タスク管理コンテキストなるものにそのまま一致する、という形になります。
参考にしたのは、この後に続く、 domain や application といった切り口です。
そしてその中に具体的なトップレベルのモジュールや集約などの概念が登場します。

ドメインレイヤー

ビジネスロジックを表現していきます。

例外

ドメイン層の共通例外クラスを定義しました。

domain/shared/domain-exception.ts
export class ValidationDomainException extends Error {}

export class UnexpectedDomainException extends Error {}

ひとまず用意したのは、バリデーション例外と、想定外例外のみです。
簡単に標準の例外を継承しただけになります。
ドメイン層例外は主にビジネスルールに対する違反を指します。
特定のプロトコルなどには依存しない概念になっております。

ユーザーメールアドレス バリューオブジェクト

domain/user/user-email-address.value-object.ts
export class UserEmailAddress {
  private static readonly USER_EMAIL_ADDRESS_PATTERN =
    /^[a-zA-Z0-9_+-]+(\.[a-zA-Z0-9_+-]+)*@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/;

  private _value: string;

  constructor(value: string) {
    if (!UserEmailAddress.USER_EMAIL_ADDRESS_PATTERN.test(value)) {
      throw new InvalidUserEmailAddressFormatException();
    }
    this._value = value;
  }

  get value() {
    return this._value;
  }
}

export class InvalidUserEmailAddressFormatException extends ValidationDomainException {
  constructor() {
    super(`Invalid email address format.`);
  }
}

ユーザーメールアドレスのバリューオブジェクトを作成しました。
バリデーションと値のカプセル化のみを提供する最低限の実装です。
コンストラクターもそのまま公開しております。

ユーザーID バリューオブジェクト

domain/user/user-id.value-object.ts
export class UserId {
  _userIdBrand!: never;

  constructor(readonly value: string) {}
}

続いて、ユーザーIDのバリューオブジェクトを作成しました。
とりあえずユーザーIDはユニークであってほしい、それ以外の要望は特にありません。

型システムが Structural Type な言語を使用する場合、このような単純な構造のエンティティIDには ブランドプロパティ を持たせておくと良いでしょう。

また、IDを生成するための専用のファクトリを定義しました。

domain/user/user-id.value-object.ts
export abstract class UserIdFactory {
  abstract handle(): UserId;
}

ドメイン層では、 "やり方はなんでも良いからとりあえずユニークなユーザーIDを生成してほしい!" というパッションだけ表現しておきます。

ユーザー 集約ルート

domain/user/user.aggregate-root.ts
export class User {
  constructor(
    readonly id: UserId,
    readonly name: string,
    readonly emailAddress: UserEmailAddress,
  ) {}
}

境界内部のオブジェクトが揃ってきたので集約ルートを作成します。
現状ユーザーの振る舞いは特にありません。

公開されたコンストラクターで、直接ドメインオブジェクトを受け取る形になっています。
つまり、集約外のリソースで境界内部のオブジェクトを作成してもらい、それをむき出しのコンストラクターに渡してもらう想定になっております。
構築が単純な集約ルートに関しては基本的にこのような構築方針にします。

ユーザー リポジトリ

domain/user/user.repository.ts
export abstract class UserRepository {
  abstract insert(user: User): Promise<void>;
  abstract find(): Promise<User[]>;
  abstract findOneById(id: UserId): Promise<User | undefined>;
  abstract findOneByEmailAddress(
    userEmailAddress: UserEmailAddress,
  ): Promise<User | undefined>;
}

集約ルートができたので対応するリポジトリを作成します。
必要なインターフェースがいくつか宣言されています。

メールアドレス重複確認 ドメインサービス

domain/user/user-email-address-is-not-duplicated.domain-service.ts
export class UserEmailAddressIsNotDuplicated {
  constructor(private readonly userRepository: UserRepository) {}

  /**
   * @throws {DuplicatedUserEmailAddressException}
   */
  async handle(userEmailAddress: UserEmailAddress) {
    if (await this.userRepository.findOneByEmailAddress(userEmailAddress)) {
      throw new DuplicatedUserEmailAddressException();
    }
  }
}

export class DuplicatedUserEmailAddressException extends ValidationDomainException {
  constructor() {
    super(`User email address is duplicated.`);
  }
}

重複の確認をメールアドレスやユーザー自身に問い合わせる、というのはモデリングとして無理があります。
これらは、自身以外のメールアドレスのことを知りようがないし、また知るべきではありません。
メールアドレスの重複確認はドメインサービスで表現します。
クライアントでは、こちらのドメインサービスを使用してからユーザー集約ルートを生成してもらいます。
重複が確認された場合、ビジネスルール違反例外が発生します。

そもそもファクトリで不変条件を強制してユーザー集約ルートを生成する

以下のようにファクトリ経由でユーザー集約ルートを生成するのもありかと思います。

user.factory.ts
export class UserFactory {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly userIdFactory: UserIdFactory,
  ) {}

  async handle(name: string, emailAddress: string) {
    const userEmailAddress = new UserEmailAddress(emailAddress);
    if (await this.userRepository.findOneByEmailAddress(userEmailAddress)) {
      throw new DuplicatedUserEmailAddressException();
    }

    const userId = this.userIdFactory.handle();

    return new User(userId, name, userEmailAddress);
  }
}

とりあえず簡単に、名前とメールアドレスから新規ユーザーを生成することに特化したファクトリを、いきなり具象から作ってみました
(実際のファクトリは ID生成や永続化技術基盤への問い合わせなどを丸っと抽象化する ことが多いため、このような具象を作ることは少なそうです)。

前者のドメインサービスの場合、 "メールアドレスは重複してはならない" というドメイン知識そのものは、ドメイン層で適切に表現できていて、それ自体をカプセル化できていると言えるでしょう。
ただし、 "ドメインサービスで重複チェックしてからユーザーを生成しなければいけない" という事態は、やはり責務がドメイン層から流出してしまっており、不安定な手続的実装をクライアントは強いられてしまっている、とも言えます。
ファクトリで、不変条件の強制を含む生成処理をカプセル化することで、このような事態を避けることができます。

(モジュールを利用したアクセスの保護)

モジュールは関連する概念を理解しやすい形にまとめあげ、モジュール間の結合度を下げたり、アクセスできるリソースを制限する機能を提供します。
Javaではパッケージ、C#では名前空間でモジュールを実装できますが、TypeScriptにはこれらに該当する機能がありません。
理想としてはファクトリからしか集約ルートの生成メソッドを呼び出せないようにモジュールで制御したいところです。

続いて、 domain.task モジュールを作っていきます

コメントID バリューオブジェクト

domain/task/comment/comment-id.value-object.ts
(UserId と同じ実装内容)
export class CommentId {
  _commentIdBrand!: never;

  constructor(readonly value: string) {}
}

export abstract class CommentIdFactory {
  abstract handle(): CommentId;
}

コメント エンティティ

domain/task/comment/comment.entity.ts
export class Comment {
  constructor(
    readonly id: CommentId,
    readonly userId: UserId,
    readonly content: string,
    readonly postedAt: Date,
  ) {}
}

コメントは UserId を持ちます
(境界内部のオブジェクトも他の集約ルートへの参照を保持することができる)。
集約ルートへの関連をID参照で表現するのは、 IDDD で紹介されていた手法です。
エンティティをコンパクトに扱いやすくし、またパフォーマンスの向上にも貢献できます
(他の集約ルートそのものを丸ごと参照できてしまうと、 "単一のコマンドが単一のトランザクションで単一の集約を操作する" という基本的な方針に違反することに繋がりかねない)。

コメント エンティティ ファーストクラスコレクション

domain/task/comment/comment.entity.first-class-collection.ts
export class Comments {
  private static readonly COMMENT_NUMBER_LIMIT = 20;

  constructor(private readonly _value: Comment[]) {}

  get value() {
    return [...this._value].sort(
      (commentA, commentB) =>
        -(commentA.postedAt.getTime() - commentB.postedAt.getTime()),
    );
  }

  add(comment: Comment) {
    if (this._value.length >= Comments.COMMENT_NUMBER_LIMIT) {
      throw new CommentNumberExceededException(Comments.COMMENT_NUMBER_LIMIT);
    }

    return new Comments([...this._value, comment]);
  }
}

export class CommentNumberExceededException extends ValidationDomainException {
  constructor(commentNumberLimit: number) {
    super(`Can't add more than ${commentNumberLimit} comments.`);
  }
}

コメントのコレクションにまつわる不変条件はタスクエンティティが直接表現しても良いです。
しかし、ファーストクラスコレクションにカプセル化した方が、その責務をより凝集でき、タスクエンティティの実装が単純で分かりやすいものになります。

コメントの一覧は常に降順で見たい

domain/task/comment/comment.entity.first-class-collection.ts
get value() {
  return [...this._value].sort(
    (commentA, commentB) =>
      -(commentA.postedAt.getTime() - commentB.postedAt.getTime()),
  );
}

基本的に表示上の責務はドメインオブジェクトではあまり表現したくありません。
微妙なところですが、今回の降順での参照はドメインモデル図上でも表現されている高レベルな要件としてファーストクラスコレクションに直接実装します。

一つのタスクへのコメント上限は20件まで

domain/task/comment/comment.entity.first-class-collection.ts
add(comment: Comment) {
  if (this._value.length >= Comments.COMMENT_NUMBER_LIMIT) {
    throw new CommentNumberExceededException(Comments.COMMENT_NUMBER_LIMIT);
  }

  return new Comments([...this._value, comment]);
}

既に20件を満たしていたら例外をthrowします。

タスク名 バリューオブジェクト

domain/task/task-name.value-object.ts
export class TaskName {
  private static readonly TASK_NAME_CHARACTERS_LIMIT = 50;

  private _value: string;

  constructor(value: string) {
    if (value.length > TaskName.TASK_NAME_CHARACTERS_LIMIT) {
      throw new TaskNameCharactersExceededException(
        TaskName.TASK_NAME_CHARACTERS_LIMIT,
      );
    }
    this._value = value;
  }

  get value() {
    return this._value;
  }
}

export class TaskNameCharactersExceededException extends ValidationDomainException {
  constructor(taskNameCharactersLimit: number) {
    super(
      `Task name can't be longer than ${taskNameCharactersLimit} characters.`,
    );
  }
}

50文字を超えた場合例外をthrowします。

タスクID バリューオブジェクト

(他のエンティティIDと同じ実装内容)

domain/task/task-id.value-object.ts
export class TaskId {
  _taskIdBrand!: never;

  constructor(readonly value: string) {}
}

export abstract class TaskIdFactory {
  abstract handle(): TaskId;
}

タスク 集約ルート

domain/task/task.aggregate-root.ts
export class Task {
  private constructor(
    readonly id: TaskId,
    readonly name: TaskName,
    private _comments: Comments,
    private _userId?: UserId,
  ) {}

  get comments() {
    return this._comments.value;
  }

  get userId() {
    return this._userId;
  }

  static create(id: TaskId, name: TaskName) {
    return new Task(id, name, new Comments([]));
  }

  static reconstitute(
    id: TaskId,
    name: TaskName,
    comments: Comment[],
    userId?: UserId,
  ) {
    return new Task(id, name, new Comments(comments), userId);
  }

  addComment(comment: Comment) {
    this._comments = this._comments.add(comment);
  }

  assignUser(userId: UserId) {
    this._userId = userId;
  }
}

タスク集約ルートにはユビキタス言語を反映したいくつかの振る舞いがあります。

  • コメントの追加 - addComment
  • ユーザーに割り当てる - assignUser

またコンストラクターをプライベートにして生成と再構成それぞれのインターフェースを用意しました。

  • 生成 - static create
    • コメントは0件
    • 誰にも割り当てられていない
  • 再構成 - static reconstitute
    • こちらは、リポジトリからのみ使用する、などの自己規律力が必要になります
      ただし、再構成(reconstitute、reconstruct)というのは DDD における重要なキーワードで、アプリケーション層などで誤って実行してしまう開発者はそんなにいないだろう、という期待

タスク リポジトリ

domain/task/task.repository.ts
export abstract class TaskRepository {
  abstract insert(task: Task): Promise<void>;
  abstract update(task: Task): Promise<void>;
  abstract find(): Promise<Task[]>;
  abstract findOneById(id: TaskId): Promise<Task | undefined>;
}

ユーザーの時と同様、集約ルートに対応するリポジトリを定義します。

おわりに

読んでくださりありがとうございました。
次回、アプリケーションレイヤーについてまとめます。
本記事で紹介したドメイン層のリソースを用いてユースケースを実現していきます。

参考文献

レバテック開発部

Discussion