Open4

【値オブジェクト】ドメイン駆動設計入門を TypeScript のコードを書きながら読み進めるスクラップ

ShoheiShohei

フロントエンジニアが ドメイン駆動設計入門 を読んで、手を動かしながら理解を深めているスクラップ。

https://amzn.to/3rO4fzF

値オブジェクト

の特徴

不変である

  • 代入は行えるが、元々コードが持つ値を更新するわけではない。
  • 値オブジェクトは値である。値を表すための値オブジェクトが、自身の値を変更するメソッドを持つのは不自然。別のクラスで定義するべき。

交換が可能である

  • 交換 = 代入

等価性によって比較される

  • 0 == 0 // true だが、左右の 0 はインスタンスとしては別のはず。つまり、値の比較とは値自身ではなく、それを 構成する属性によって比較される。同様に値オブジェクトも構成する属性 (インスタンス変数) によって比較される。
  • 表現としての不自然さを回避する
    • 値の場合 0 == 0 と書く。値オブジェクトの場合 instanceA.value == instanceB.value とするはずだが、値と表現が違う。
    • instanceA.equals(instanceB) といった自然な書き方を実装する。値オブジェクトに比較のためのメソッドを実装する。

FullName クラス

FullName クラス
class FullName {
  private _firstName: string;
  private _lastName: string;

  constructor(firstName: string, lastName: string) {
    this._firstName = firstName;
    this._lastName = lastName;
  }

  get FirstName() {
    return this._firstName;
  }

  get LastName() {
    return this._lastName;
  }

  public Equals(value: FullName): boolean | Error {
    if (!(value instanceof FullName)) {
      throw new Error("型がちがうよ");
    }
    if (value == null) {
      return false;
    }

    /**
     * 同じインスタンスの場合、ここで true となる。
     */
    if (this == value) {
      return true;
    }

    /**
     * インスタンスとして違っていても、それぞれの属性が同じであれば true となる。
     */
    return (
      this._firstName === value.FirstName && this._lastName === value.LastName
    );
  }
}

const ore = new FullName("Taro", "Yamada");
const watashi = new FullName("Taro", "Yamada");

console.log(ore.Equals(watashi)) // true
console.log(ore.Equals(ore)) // true
console.log(ore.Equals({ _firstName: 'Taro', _lastName: 'Yamada' })) // 型が違うためエラー
ShoheiShohei

どこまで値オブジェクトで表現するか

FullName クラスの場合

  • firstName, LastName はプリミティブな文字列としていたけど本当にそれでいい?という疑問。

姓名も値オブジェクトにした場合

  • FirstName クラスと LastName クラスを実装。
    • 1文字以上、半角英字のみの文字列でバリデーション。
  • そもそも別々のクラスにするほど挙動が違うものではないかもしれない。
FirstName クラスと LastName クラスを実装
class FirstName {
  private _value: string;

  constructor(value: string) {
    if (!value) {
      throw new Error("1文字以上必要だよ");
    }
    if (!this.ValidateName(value)) {
      throw new Error("文字形式が違うよ");
    }
    this._value = value;
  }

  /**
   * 半角文字のみ、空文字 NG
   */
  private ValidateName(value: string): boolean {
    const reg = new RegExp(/^[a-zA-Z]+$/);
    return reg.test(value);
  }
}

class LastName {
  private _value: string;

  constructor(value: string) {
    if (!value) {
      throw new Error("1文字以上必要だよ");
    }
    if (!this.ValidateName(value)) {
      throw new Error("文字形式が違うよ");
    }
    this._value = value;
  }

  /**
   * 半角文字のみ、空文字 NG
   */
  private ValidateName(value: string): boolean {
    const reg = new RegExp(/^[a-zA-Z]+$/);
    return reg.test(value);
  }
}

class FullName {
  private _firstName: FirstName;
  private _lastName: LastName;

  constructor(firstName: FirstName, lastName: LastName) {
    this._firstName = firstName;
    this._lastName = lastName;
  }

  public Equals(value: FullName): boolean | Error {
    if (!(value instanceof FullName)) {
      throw new Error("型がちがうよ");
    }
    if (value == null) {
      return false;
    }

    /**
     * 同じインスタンスの場合、ここで true となる。
     */
    if (this == value) {
      return true;
    }

    /**
     * インスタンスとして違っていても、それぞれの属性が同じであれば true となる。
     */
    return (
      this._firstName === value.FirstName && this._lastName === value.LastName
    );
  }
}

const firstNameInJapanese = new FirstName("たろう"); // バリデーションエラー
const lastNameInJapanese = new LastName("やまだ"); // バリデーションエラー

const firstNameInAlphabet = new FirstName("Taro");
const lastNameInAlphabet = new LastName("Yamada")
const ore = new FullName(firstNameInAlphabet, lastNameInAlphabet);
const watashi = new FullName(firstNameInAlphabet, lastNameInAlphabet);
console.log("名前比較: ", ore.Equals(watashi));

姓名を同じクラスで実装する場合

  • FirstName クラスと LastName クラスをまとめる
Name クラスを実装
class Name {
  private _value: string;

  constructor(value: string) {
    if (!value) {
      throw new Error("1文字以上必要だよ");
    }
    if (!this.ValidateName(value)) {
      throw new Error("文字形式が違うよ");
    }
    this._value = value;
  }

  /**
   * 半角文字のみ、空文字 NG
   */
  private ValidateName(value: string): boolean {
    const reg = new RegExp(/^[a-zA-Z]+$/);
    return reg.test(value);
  }
}

class FullName {
  private _firstName: Name;
  private _lastName: Name;

  constructor(firstName: Name, lastName: Name) {
    this._firstName = firstName;
    this._lastName = lastName;
  }

  public Equals(value: FullName): boolean | Error {
    if (!(value instanceof FullName)) {
      throw new Error("型がちがうよ");
    }
    if (value == null) {
      return false;
    }

    /**
     * 同じインスタンスの場合、ここで true となる。
     */
    if (this == value) {
      return true;
    }

    /**
     * インスタンスとして違っていても、それぞれの属性が同じであれば true となる。
     */
    return (
      this._firstName === value.FirstName && this._lastName === value.LastName
    );
  }
}

const firstName = new Name("Taro");
const lastName = new Name("Yamada")
const ore = new FullName(firstName, lastName);
const watashi = new FullName(firstName, lastName);
console.log("名前比較: ", ore.Equals(watashi));
ShoheiShohei

Money クラス

  • 通貨名と金額を指定できる
  • 加算処理がある
Money クラス
class Money {
  private readonly _amount: number;
  private readonly _currency: string;

  constructor(amount: number, currency: string) {
    if (currency === null) {
      throw new Error("エラー");
    }
    this._amount = amount;
    this._currency = currency;
  }

  get Amount() {
    return this._amount;
  }

  get Currency() {
    return this._currency;
  }

  public Add(arg: Money): Money {
    if (this._currency !== arg.Currency) {
      throw new Error("通貨単位が違う");
    }
    return new Money(this._amount + arg.Amount, this._currency);
  }
}

const senen = new Money(1000, "JPY");
const judoru = new Money(10, "USD");
const result = senen.Add(judoru); // 通貨が違うためエラー

const nisenen = new Money(2000, "JPY");
const sanzenen = senen.Add(nisenen)
console.log(`${sanzenen.Amount}: [${sanzenen.Currency}]`) // 3000 [JPY]
ShoheiShohei

値オブジェクトを採用するモチベーション

表現力を増す

  • プリミティブな値では表現しきれないものを表現する。
  • 自己文書化をはかれる。
製品番号を表す値オブジェクト (ModelNumber)
class ModelNumber {
  private readonly _productCode: string;
  private readonly _branch: string;
  private readonly _lot: string;

  constructor(productCode: string, branch: string, lot: string) {
    const args = [productCode, branch, lot];    
    const invalidArgs = args.filter((arg) => arg == null);
    
    if (invalidArgs.length) {
      invalidArgs.forEach((arg) => {
        const key = Object.keys({ arg })[0]
        throw new Error(`${key} が入ってないよ`);
      });
    }

    this._productCode = productCode;
    this._branch = branch;
    this._lot = lot;
  }
}

const mn = new ModelNumber('11', '22') // => arg がはいってないよ

const mn = new ModelNumber('11', '22', '33')
console.log(mn.ToString()) // => "11-22-33"

不正な値を存在させない

バリデーションかけることで不正な値の存在を予防する (UserName)
class UserName {
  private readonly _name: string;

  constructor(value: string) {
    if (value === null) {
      throw new Error('文字がないよ')
    }
    if (value.length < 3) {
      throw new Error('文字が2文字以下だよ')
    }
    this._name = value
  }
}

誤った代入を防ぐ

  • コードの正しさをコードで表現する。
コードが正しいのか分からない (User)
class User {
  private readonly _name: string;
  public _id: any;

  constructor(value: string) {
    this._name = value;
  }

  get Name() {
    return this._name;
  }

  public CreateUser(name: string): User {
    const user = new User(name);
    
    /**
     * id と名前が同じになるサービスはあるにはあるけど、本当にこれでいいのかが分からない。
     * コードの正しさをコードで表現するべき。
     */
    user._id = user.Name;
    return user;
  }
}
UserName, UserId クラスを定義し、タイプ不一致エラーを検知する。 (UserId, UserName, User)
class UserId {
  private readonly _id: string;

  constructor(value: string) {
    if (value === null) {
      throw new Error('文字がないよ')
    }
    this._id = value
  }
}

class UserName {
  private readonly _name: string;

  constructor(value: string) {
    if (value === null) {
      throw new Error("文字がないよ");
    }
    if (value.length < 3) {
      throw new Error("文字が2文字以下だよ");
    }
    this._name = value;
  }
}

class User {
  private _name: UserName;
  private _id: UserId;

  constructor(value: UserName) {
    this._name = value;
  }

  get Name() {
    return this._name;
  }

  set Name(name: UserName) {
    this._name = name
  }

  get Id() {
    return this._id
  }

  set Id(id: UserId) {
    this._id = id
  }

  public CreateUser(name: UserName): User {
    const user = new User(name);
    user.Id = user.Name; // ここでエラーを検知
    return user;
  }
}

ロジックの散在を防ぐ

  • 例えばユーザー作成と更新の処理があったとき、ユーザー名に関するバリデーションルールを別々のクラスに実装すると、管理が大変。
  • 理想は、ルールの変更に対してコードの変更箇所が1箇所で済む状態。値オブジェクトを定義してルールをまとめよう。
値オブジェクトにルールをまとめる
class UserName {
  private readonly _name: string;

  constructor(value: string) {
    if (value === null) {
      throw new Error("文字がないよ");
    }
    if (value.length < 3) {
      throw new Error("文字が2文字以下だよ");
    }
    this._name = value;
  }
}

class User {
  private _name: UserName;
  private _id: number;

  constructor(value: UserName) {
    this._name = value;
  }

  get Name() {
    return this._name;
  }

  set Name(name: UserName) {
    this._name = name
  }

  public CreateUser(name: UserName): User {
    const user = new User(name);
    this._id = 1; // 適当に置いてる
    return user;
  }

  /**
   * id をもとにユーザーを取得する処理。
   */
  public getUserById(id: number): User {
    // DB から ユーザーを取得する処理を書く。
    const userName = new UserName('sample')
    return new User(userName)
  }

  public UpdateUser(id: number, name: string): User {
    const targetUser = this.getUserById(id)

    // DB のデータを上書きする処理を書く。

    return targetUser
  }
}