LoginSignup
0
0

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

Last updated at Posted at 2024-01-14

DDD の戦術的設計パターンを実践します。
以下のセクションに分かれております。

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

記事内のソースコードの記述等をなるべくコンパクトにするために、完全にボトムアップで実装していきます。

言語 ・ フレームワーク

TypeScript ・ NestJS

採用アーキテクチャー

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

リポジトリ

テーマ

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

今回戦術的設計パターンにフォーカスしますが、一応簡単にユースケース図っぽいものと、ドメインモデル図っぽいものを用意しました。

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

usecase.png

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

ユーザーを作成するアクターについてはまだよく分かっておりません。
とりあえず、みんながみんな自由にユーザーをほいほい作成できてしまうような状況は避けたい、という意見が出ています。
アジャイル的に、まだ決まってない部分に関しては注釈や議論のメモを残したりしながら実装を進めていきます、、が、アクターが不明というパターンはかなり稀かもしれません。

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

domainmodel.drawio.png

各エンティティの振る舞いや多重度と共に、いくつかの制約を記載しております。
タスクに対するコメントの上限が20件である意味が少し不明ですが、学習テーマとして良さそうな制約だと思ったため組み込ませていただきました。

理想としては、全てのユビキタス言語への対訳やバリューオブジェクトについての記載なども欲しいところですが、今回は省略しました。

ドメインモデル図はユースケース図を作ってから作成しますが、ドメインモデル図に表現されるのは、特定のユースケースなどを越えたそもそもの不変条件になります。

ディレクトリ構成

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

src
 ┣ application
 ┃ ┣ auth
 ┃ ┣ shared
 ┃ ┣ task
 ┃ ┗ user
 ┣ domain
 ┃ ┣ shared
 ┃ ┣ task
 ┃ ┗ user
 ┣ infrastructure
 ┃ ┣ in-memory
 ┃ ┣ mysql
 ┃ ┗ uuid
 ┗ presentation
   ┣ http
   ┗ nest-commander

オニオンアーキテクチャーを意識した構成になっております
(presentaion は user-interface というディレクトリ名でも良いです)。

NestJS を使うならフィーチャーベースで切るべきでは? というお声もあるかと思われます
(src/task/domain とか src/task/application みたいなイメージ)。
それも可能かもしれませんが、今回は 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.`);
  }
}

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

いくつかツッコミどころがありそうなので確認していきます。

value に対するプライベートなセッターは必要か?

IDDD ではドメインオブジェクトの各プロパティに対してプライベートなセッターを用意し(自己委譲)、セッター内でバリデーションを行っていました。
とりわけ複数のconstructorを用意する場合や、プロパティを複数持つドメインオブジェクトの場合、各プロパティの制約をセッターに自己カプセル化した方が、よりシンプルかつ拡張性の高い実装になるでしょう。
今回は、わざわざ自己委譲するうまみも無かったので、constructorしか用意しませんでした。

具体的な例外クラスは必要か?

今回メールアドレスのフォーマット違反を示す具体的な例外クラス(InvalidUserEmailAddressFormatException)を用意しましたが、
これは特に DDD において必須の概念ではありません。
Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考 で、特定の例外を示す独自の例外クラスを実装していたのを参考にしました。

  • 具体的かつ説明的な例外クラスは可読性の向上と問題の特定に貢献する
  • 個別の例外特有の振る舞いをカプセル化することができる
    • エラーメッセージの設定や
    • デバックを助けるメタ情報を付与したり
    • (あくまでドメイン層の実装として不自然にならない範囲で)

また、ドメイン層を実装する上で外のレイヤーのことはあまり気にするべきではありませんが、
多くのユースケースが複数のドメインオブジェクトの振る舞いを呼び出す都合上、とりわけドメイン層例外に関しては特定が容易になっていた方が嬉しいです。
具体的な例外クラスを用意することで、ユースケースないしはユースケースのクライアントは、受け取った例外に応じた柔軟な対応をすることが可能になります
(どのような粒度でエラーハンドリングするかにもよりますが)。

"Validation" という表現は適切か?

InvalidUserEmailAddressFormatExceptionValidationDomainException を継承しますが、今回の実装において "Validation" という表現は厳密には不適切かもしれません。
IDDD では、各プロパティに対しての検証をガードとして分類し、バリデーションとは区別していました。
ガードというのは有効なパラメーターについての表明のことで、今回の場合でいうと、以下がこれに該当します

example.value-object.ts
if (!UserEmailAddress.USER_EMAIL_ADDRESS_PATTERN.test(value)) {
  throw new InvalidUserEmailAddressFormatException();
}

(ただこれ厳密には "表明" というより "検査" が正しい気も?)。

IDDD で、ガードによって投げられていた例外は、 IllegalArgumentException すなわち引数が不正であるという例外で、バリデーションという概念は登場していませんでした。
対してバリデーションはどちらかというと、エンティティの中の全てのプロパティを横断した 遅延バリデーション などのことを指し、その詳細をドメインオブジェクトとは別のリソースとして実装します。

今回私はシンプルに、 個々の値やその他不変条件を検証するような概念は全て "Validation" と粗く形容 して実装しております。
そして、本来の意味でのバリデーター(オブジェクト全体のプロパティを横断して検証し全てのエラーを集計して通知する) と言われるようなものは実装しておりません。

一応いくつか他の実装パターンも確認してみます

等価性の確認

等価性確認の振る舞いを実装することはよくあるかと思われます。
メリットとして、

  • クライアントがバリューオブジェクトの内部構造を熟知していなくても等価性を確認することができる
  • 単純に保守性を高められる
  • (TypeScript では不可) 演算子を使った比較をオーバーライドすることで値オブジェクト同士の比較を、自然な記述で表現できる
    • FooValueObjectA == FooValueObjectB みたいな
    • 値オブジェクトはあくまでも概念的に統一された "値" であり、このような記述の方が自然である

今回作成したメールアドレスはプリミティブなプロパティを一つだけ持つ単純なバリューオブジェクトですが、バリューオブジェクトの形式は様々です。

example.value-object.ts
class Name {
  constructor(
    private readonly firstName: string,
    private readonly lastName: string,
  ) {}

  equals(name: Name) {
    return this.firstName === name.firstName && this.lastName === name.lastName;
  }
}

クライアント側のいたるところでプロパティを直接参照して比較していると、バリューオブジェクトにプロパティが追加された場合に修正漏れが発生する可能性があります。
逆に equals を使用していれば、変更箇所を equals のみにまとめあげることができます。

(ミドルネームが追加された場合)

example.value-object.ts
class Name {
  constructor(
    private readonly firstName: string,
    private readonly lastName: string,
    private readonly middleName: string,
  ) {}

  equals(name: Name) {
    return (
      this.firstName === name.firstName &&
      this.lastName === name.lastName &&
      this.middleName === name.middleName // Compare by middlename.
    );
  }
}

今回私はバリューオブジェクトには実際にソース内で必要となった最低限の振る舞いしか実装しませんでしたが、属性(と型)の比較による等価性確認はバリューオブジェクトの基本的な特徴の一つです。
実践では等価性確認の振る舞いを実装することもあるかと思われます
(等価性確認の振る舞いを持つレイヤースーパータイプを用意するなど)。

再構成時はバリデーションをしないようにする

"新規オブジェクトを生成する場合、不変条件が満たされなければ、ファクトリは処理を中止するだけでよいが、再構成の場合には、より柔軟な対応が必要になることがある。すでにオブジェクトがシステムのどこか(データベース中など)に存在しているならば、その事実を無視することはできない。"
(エリック・エヴァンスのドメイン駆動設計: ソフトウェアの核心にある複雑さに立ち向かう)

こちらの問題に対処する方法は様々だと思いますが、例として、バリデーションの責務をファクトリに委譲することはできそうです。

example.value-object-and-factories.ts
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,}$/;

  constructor(readonly value: string) {}

  static assertEmailAddressPatternValid(value: string) {
    if (!UserEmailAddress.USER_EMAIL_ADDRESS_PATTERN.test(value)) {
      throw new InvalidUserEmailAddressFormatException();
    }
  }
}

class CreateUser {
  handle(emailAddress: string) {
    UserEmailAddress.assertEmailAddressPatternValid(emailAddress);

    return new User(new UserEmailAddress(emailAddress));
  }
}

class ReconstituteUser {
  handle(emailAddress: string) {
    return new User(new UserEmailAddress(emailAddress));
  }
}

再構成用ファクトリ(ReconstituteUser)ではバリデーションをしておりません。

他には、生成と再構成それぞれのファクトリインターフェースをバリューオブジェクトクラス自身で持つこともできそうです。

example.value-object.ts
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 constructor(readonly value: string) {}

  static create(value: string) {
    if (!UserEmailAddress.USER_EMAIL_ADDRESS_PATTERN.test(value)) {
      throw new InvalidUserEmailAddressFormatException();
    }

    return new UserEmailAddress(value);
  }

  static reconstitute(value: string) {
    return new UserEmailAddress(value);
  }
}

ただ、 reconstitute はバリューオブジェクトクラス自身のインターフェースとしては少々不純に思えます。
強いていうならば、集約ルートが reconstitute というインターフェースを持つのはまだしっくりくるかもしれません(あまり望ましくはなさそうですが)。
というのも、基本的に再構成というのは、何らかの永続化基盤から集約ルートやエンティティを組み立て直す概念だからです。
境界内部のオブジェクトの生成と再構成の区別、というのはその過程で必要になるかもしれない実装にすぎず、それをバリューオブジェクトクラス自身のインターフェースとして持つのは禁じ手かもしれません。

いずれにしろ、再構成時のバリデーションについて考慮しなくてはいけない場合もある、ということになります。

("不変条件" という言葉) 話が脱線しますが、今回の例のようなメールアドレスのフォーマットも広く言えば、不変条件の一部、、かもしれませんが、不変条件という言葉がこのような文脈で使用されることは少ないです。
どちらかというと、集約で維持し続けなければいけないもっと複雑なビジネスルールなどを指す場合に "不変条件" という言葉が使われます。

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

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

  constructor(readonly value: string) {}
}

続いて、ユーザーIDのバリューオブジェクトを作成しました。
特に文字数の制限なども設けておらず、かなり無口なクラスになってしまいました。
とりあえずユーザーIDはユニークであってほしい、それ以外の要望は特にありません。

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

IDDD ではユーザーIDの生成を Repository で行っていました。
Repositoryの実装クラスでは、特定の永続化メカニズムやデータストアの機能を使ったID生成をしても良いし、サンプルコードのように 組み込みのモジュールを使用する のも良いのでしょう。
強いていうならこのやり方の場合、LevelDBRepository なのに、 LevelDB の機能を使ってないことに違和感を覚える人もいるかも? しれません。
また理想を言うと、リポジトリの戻り値は集約ルートであって欲しいものです。

一応今回は、IDを専用のファクトリ経由で生成することにしました。

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

IdFactoryの実装には、RDBの機能や、Uuidフレームワークの機能など、いかなる技術を使っても良いです。
要件に最適な実装を都度選択する形になります。

example.id-factory's-impls.ts
class RdbUserIdFactory implements UserIdFactory {
  handle(): UserId {
    // Use RDB
  }
}

class UuidFrameworkUserIdFactory implements UserIdFactory {
  handle(): UserId {
    // Use UUID framework
  }
}

ユーザー 集約ルート

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

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

集約ルートはほとんどの場合特定のエンティティになります。
ユーザーはidという識別子を持つエンティティであり、かつ集約ルートになります。

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

ユーザー リポジトリ

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>;
}

集約ルートができたので対応するリポジトリを作成します。
必要なインターフェースがいくつか宣言されています。
例えば、タスクにユーザーをアサインする際に選択肢として必要なユーザーの一覧を find 経由で参照する、など。

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

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.`);
  }
}

重複の確認をメールアドレスやユーザー自身に問い合わせる、というのはモデリングとして無理があります。
これらは、自身以外のメールアドレスのことを知りようがないし、また知るべきではありません。

メールアドレスの重複確認はドメインサービスで表現します
(抽象だけ定義するのもありです)。
クライアントでは、こちらのドメインサービスを使用してからユーザー集約ルートを生成してもらいます。
ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本 では重複結果を真偽値で返すようなドメインサービスが紹介されていました。
エヴァンズ本にはドメインサービスの戻り値はドメインオブジェクトであるべき、とありましたが、今回のようなドメインサービスの場合、真偽値を返す方がインターフェースとして自然で分かりやすいかもしれません。

今回私が例外を投げる実装にした理由は、メールアドレスの重複が明確なビジネスルール違反だということを表現するためです
(戻り値がただの真偽値だとビジネスルール違反としての表現力には少し欠けるのと、クライアント側も一定手続き的な実装を強いられてしまいそう)。

一方、 void もかなり歪なので、

  • ビジネスルール違反を示すような Result型
  • 存在の有無や重複 を表現するユニバーサルなドメインオブジェクト

などを用意して戻り値とするのも良さそうです。

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

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

example.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生成や永続化技術基盤への問い合わせなどを丸っと抽象化する ことが多いため、このような具象を作ることは少なそうです)。

ファクトリを導入する理由、またその実装方法も様々だと思いますが、不変条件の強制というのはファクトリにおける重要なテーマです
(ちなみにファクトリには大きく分けて、ユビキタス言語を表現したファクトリ実装上必要なファクトリ の2種類があり、今回の UserFactory実装上必要なファクトリ に該当します)。

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

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

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

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

domain/task/comment/comment-id.value-object.ts
export class CommentId {
  _commentIdBrand!: never;

  constructor(readonly value: string) {}
}

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

(UserId と同じ実装内容)

コメント エンティティ

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 で紹介されていた手法です。
エンティティをコンパクトに扱いやすくし、またパフォーマンスの向上にも貢献できます。

ここでモジュールを越えた依存が発生しました
(実際モジュールそのものは実装できておりませんが、今回TypeScriptで実装するにあたってモジュールを模したものをディレクトリ構成で表現しております)。

domain/task/comment/comment.entity.ts
import { UserId } from '../../user/user-id.value-object';

ただしこれは許容される依存になるでしょう。

いくつか代替案もあるのですが、どれもおすすめできません。
一応確認していきます。

shared に UserId を配置する

共有されるオブジェクトを何でもかんでも shared に配置してしまうと、そのリソースが何に由来して定義された概念なのかが分かりづらくなってしまいます。
UserId は明示的に userモジュール に含めておくべきリソースでしょう。
shared には特定のドメイン知識には関心のない、もっと抽象的な共有オブジェクトやユニバーサルな概念を配置したいです。

shared に 汎用的な具象 EntityId を配置し各エンティティがそれをそのまま使用する

example.using-entity-id.ts
class EntityId {
  constructor(readonly value: string) {}
}

class User {
  constructor(
    readonly id: EntityId,
  ) {}
}

class Comment {
  constructor(
    readonly userId: EntityId,
  ) {}
}

"疎結合性を保つためにこれらの識別子を汎用型で保持して、 ~(中略)~ たしかに、こうすれば疎結合性は確保できる。しかし、個々の識別子の型を他と区別できなくなるという点で、バグを埋め込んでしまう可能性が増えてしまう。"
(実践ドメイン駆動設計)

上記の通りで、誤ったIDを渡してしまう可能性などがあるため、こちらも推奨できません。

、、 などなどの理由で UserId には思い切って直接結合させます。

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

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()),
  );
}

基本的に表示上の責務はドメインオブジェクトではあまり表現したくありません。
ただし、今回は特定のユーザーインターフェースに限らず常に降順になっててほしい、そしてやることもせいぜい並び順を制御する程度です。
例えばドメインオブジェクトを、外部に公開しても良い値を組み合わせた特定の構造に変換したり、特定のエンドユーザーが見やすいフォーマットに変換するのであれば、責務違反になりそうです。
これらはDTOやプレゼンテーション層の責務に該当しそうです。

微妙なところですが、今回の降順での参照はドメインモデル図上でも表現されている高レベルな要件としてファーストクラスコレクションに直接実装します。

get value にここまでの振る舞いを持たせることには少し抵抗があるので、他の getter を定義するのも良いと思います。

example.first-class-collection.ts
get value() {
  return [...this._value];
}

get inDescendingOrder() {
  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 バリューオブジェクト

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

  constructor(readonly value: string) {}
}

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

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

タスク 集約ルート

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>;
}

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

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0