📯

TypeScript でミュータブルなエンティティ実装

2023/07/31に公開

変更可能な(ミュータブルな)ドメイン層のエンティティを実現するクラスの実装サンプルです。

ポイント

  • インスタンスの生成にclass-transformer を利用する。
    • 単純な文字列を値オブジェクト等へ変換・代入処理を簡素化します。
  • エンティティのセルフインスタンス化を禁止する。
    • エンティティのコンストラクタは隠す。
    • 代わりに、ファクトリー関数だけが知りえる継承クラスでインスタンス化できるようにする。
  • エンティティの値変更は型付き Object.assign を経由させる。

実装例

TypeScript での値オブジェクト実装」で紹介した値オブジェクトを利用します。

エンティティ

まずはメインとなるエンティティについて。
エンティティクラスで共通する実装ルールを以下のようにしました。

  • abstract class として直接生成できない抽象クラスにする。
  • エンティティの値は読み取り専用プロパティとして定義する。
  • 値変更はプライベートな専用メソッド _set() を作り、これを経由する。
user.ts
import { Transform } from "class-transformer";
import { UserId } from "./id-series";
import { NickName } from "./nick-name";
import { RealName } from "./real-name";

/**
 * ユーザー
 */
export abstract class User {
  @Transform((params) => new UserId(params.value))
  readonly id: UserId;
  
  @Transform((params) => new RealName(params.value))
  readonly realName: RealName;

  @Transform((params) => new NickName(params.value))
  readonly nickName: NickName;

  @Transform((params) => new Date(params.value))
  readonly birthDay: Date;

  equals(other?: User): boolean {
    if (!(other instanceof User)) {
      return false;
    }
    return (
      this.id.equals(other.id) &&
      this.realName.equals(other.realName) &&
      this.nickName.equals(other.nickName) &&
      this.birthDay.getTime() === other.birthDay.getTime()
    );
  }

  /** 型安全に readonly なプロパティを挿げ替える */
  private _set(source: Partial<User>): void {
    Object.assign(this, source);
  }

  /** 変更を受け付けるプロパティ */ 
  changeNickName(nickName: NickName): void {
    // _set は型定義してるので予定外の値設定による汚染リスクを静的にチェックできる
    this._set({ nickName });
  }

  /** ビジネスルールにあった変更かチェック */ 
  changeBirthDay(birthDay: Date): void {
    if (birthDay.getTime() > Date.now()) {
      throw new Error("未来の誕生日は指定できません");
    }
    this._set({ birthDay });
  }

  /** 年齢 */
  get age(base: Date = new Date()): number {
    const birthDay = this.birthDay;
    const age = base.getFullYear() - birthDay.getFullYear();
    if (
      base.getMonth() < birthDay.getMonth() ||
      (base.getMonth() === birthDay.getMonth() && base.getDate() < birthDay.getDate())
    ) {
      return age - 1;
    }
    return age;
  }
}

エンティティ生成器(ファクトリー)

エンティティのインスタンス化にはエンティティと対になるファクトリークラスを経由させます。ファクトリークラスの実装ルールを以下にしました。

  • abstract なエンティティを生成できるよう継承したクラスをモジュール内定義する。
  • 新規登録向け、データ復元向けなど用途ごとに生成メソッドを用意する。
  • どの生成メソッドでも共通する値妥当性チェックなどビジネスルールを実装する。
user-factory.ts
import { randomUUID } from "crypto";
import { plainToInstance } from "class-transformer";
import { User } from "./user";

// new でインスタンス化できるクラスはモジュール内に閉じておく
class UserInternal extends User {}

/**
 * JSON の形式で受け付けでき、設定対象となるプロパティ群の型定義
 */
export type UserPlain = {
  id: string;
  realName: string;
  nickName: string;
  birthDay: string;
};

/** User エンティティのインスタンス生成や変換を行います */
export class UserFactory {
  /**
   * 新規登録向けのエンティティを作る
   * ID は自動生成するので受け付けない。
   */
  create(plain: Omit<UserPlain, "id">): User {
    const thePlain = {
      ...plain,
      id: randomUUID(),
    };
    this.validate(thePlain);
    return plainToInstance(UserInternal, thePlain);
  }

  /**
   * エンティティを復元する
   * 主にデータベースからの変換などインフラ層が使う
   */
  transform(plain: UserPlain): User {
    this.validate(plain);
    return plainToInstance(UserInternal, plain);
  }

  /** 値の事前チェックなど */
  private validate(plain: UserPlain): void {
    if (!(plain.id typeof "string")) {
      throw new Error("id が不正です");
    }
    if (!(plain.realName typeof "string")) {
      throw new Error("realName が不正です");
    }
    if (!(plain.niclkName typeof "string")) {
      throw new Error("nickName が不正です");
    }
    if (!(plain.birthDay typeof "string")) {
      throw new Error("birthDay が不正です");
    }
    
    const birthDay = new Date(plain.birthDay);
    if (isNaN(birthDay)) {
      throw new Error("birthDay の日付が不正です");
    }
    if (birthDay.getTime() > Date.now()) {
      throw new Error("未来の誕生日は指定できません");
    }
  }
}

使ってみる

新しく生成しつつ、ニックネームを変えてみます。

main.ts
import { NickName } from "./nick-name";
import { UserFactory } from "./user-factory";

const factory = new UserFactory();
const user = factory.create({
  realName: "山田 太郎",
  nickName: "たろー",
  birthDay: "2000/07/07",
});
console.log(user.nickName); // たろー

user.changeNickName(new NickName("たろたろ"));
console.log(user.nickName); // たろたろ

// JSON 形式の文字列に変換はシンプルにできる
console.log(JSON.stringify(user));

// 未来の日付に変更できない
user.changeBirthDay(new Date("2222/01/02"));

おわりに

plainToInstance() によるクラスのインスタンス化には、パブリック定義されているコンストラクタが必須という制約があります。これと「ビジネスルールを無視した new によるインスタンスは生成したくない」という要件とに矛盾がでてきます。

new による生成を抑制するシンプルな対処はコンストラクタをプライベート定義する方法がありますが、そうすると plainToInstance() がコンストラクタが見当たらない警告で使えないジレンマになります。

この解決に抽象クラスを活用しました。インスタンスを生成できるモジュールで継承クラスを内部定義(export しないクラス)を経由するようにして隠蔽化しています。

この実装が参考になれば幸いです。

それではまた!

コラボスタイル Developers

Discussion