5/13 ドメイン駆動設計 Chapter2
値オブジェクトとは何か
システム固有の値を表現するために定義されたオブジェクトのこと
プリミティブな値でもシステムを組み上げることはできるが、値オブジェクトが必要なときもある。
プリミティブな値で氏名を表現するコード
const fullName = "haga yuki"; console.log(fullName);
実行結果
[LOG]: "haga yuki"
氏名の取り扱いはシステムによって、色々あり、フルネームを表示したいときもあれば、名字だけを表示したい場合もある。
const fullName = "haga yuki"; const tokens = fullName.split(' '); // ["haga", "yuki"] const lastName = tokens[0]; console.log(lastName);
実行結果
[LOG]: "haga"
名字だけを取り出せました。
このやり方では問題がある場合があります!
const fullName = "noppo ohashi"; const tokens = fullName.split(' '); const lastName = tokens[0]; console.log(lastName);
実行結果
[LOG]: "noppo"
氏名の構成が、名字・名前とは限らない。名前・名字のパターンもあり得るが、それに対応できないわけです。
こういった問題をオブジェクト指向プログラミングではクラスが使われます。 この時に使うクラスが値オブジェクトと呼ばれています。
値オブジェクトで氏名を表現するコード
class FullName { constructor(public firstName: string, public lastName: string) {} } const fullName = new FullName("yuki", "haga"); console.log(fullName.lastName);
FullNameクラスはコンストラクタで第一引数に名前、第二引数に名字を指定するようになっているので、このルールを守りさえすれば、順序が入れ替わる氏名でも、名字を取り出すことができます。
システムに最適な値がプリミティブな値でない場合もあり、そのシステムの必要な処理に従った上での、値の表現が必要な場合に、値オブジェクトが使われます。
このFullNameは、氏名を表現したオブジェクトであり値の表現でもある、つまり値オブジェクトと呼ばれます。
値の性質とは
- 不変である
- 代入による変更はできるが、値自体は不変である。
- 交換が可能である
- 等価性によって比較される
- 値オブジェクトも値と同様に、値オブジェクトを構成する属性(インスタンス変数)によって比較される。
- メソッドで比較を行う
- 氏名にミドルネームなどの属性が追加されてもメソッドの修正のみで良い。
値オブジェクトにする基準
ここまでの例では、firstName lastNameはプリミティブな値でFullNameクラスとして値オブジェクトを表現していましたが
firstName lastName自体もクラス宣言にして、値オブジェクトにすることもできます。
- これを、やりすぎという人もいれば、正しいという人もいる
- 正当性はコンテキスト(前後の脈絡。文脈。)によるのでどっちが正しいとも言い切れない
- 判断基準が欲しい!!
- 正当性はコンテキスト(前後の脈絡。文脈。)によるのでどっちが正しいとも言い切れない
筆者としての判断基準
- そこにルールが存在しているか?
- 氏名であれば、名字と名前で構成される、というルールがある
- それ単体で取り扱いたいか?
- 今回の例では単体で取り扱っていた
今回の例で、名字と名前にルールを設けるとしても、FullNameクラスに定義してもいいので、プリミティブな値でも問題はない。
値オブジェクトにしてもいい。するのであれば、名字と名前に分けるのか?を考える。
分ける必要がなければ、Nameクラスを作成して値オブジェクトにして、FullNameクラスの引数に渡すというやり方になる。
TypeScriptで書いてみます。(これまでの例もTypeScriptで書いていました、TypeScriptのクラス宣言を学んでからの方が理解できるかと思います)
class Name { constructor(public readonly value: string) {} equals(other: Name): boolean { return this.value === other.value; } } class FullName { constructor(public readonly firstName: Name, public readonly lastName: Name) {} equals(other: FullName): boolean { return this.firstName.equals(other.firstName) && this.lastName.equals(other.lastName); } } const firstName = new Name("yuki"); const lastName = new Name("haga"); const firstName2 = new Name("yunosuke"); const lastName2 = new Name("minegishi") const fullName = new FullName(firstName, lastName); const fullName2 = new FullName(firstName2, lastName2); console.log(fullName.equals(fullName2)); console.log(fullName); console.log(fullName2);
- FullNameクラスへの引数にNameクラスのインスタンスを渡しています。
- 先ほどはstringのプリミティブな値を引数に渡していたのをNameクラスとして値オブジェクトにした例になります。
- 等価性を判断するためのメソッドとしてequalsメソッドを定義しています。
実行結果
[LOG]: false [LOG]: FullName: { "firstName": { "value": "yuki" }, "lastName": { "value": "haga" } } [LOG]: FullName: { "firstName": { "value": "yunosuke" }, "lastName": { "value": "minegishi" } }
このように、やろうと思えば、値オブジェクトにできてしまうので、どこまでを値オブジェクトにするか?を検討します。
重要なこと
- 値オブジェクトを避けることではなく、すべきかを見極めて、すると判断したのであれば大胆に実行するべきだということです。
- 値オブジェクトとして定義するほどの価値のある概念を実装中に見つけたのであれば、ドメインモデルとしてフィードバックすべきです。
- DDDが目的とするイテレーティブな開発はこうして実装時の気づきによって支えられます。
値オブジェクトはメソッドを定義できる
先ほどのequalsメソッドのように値オブジェクトにはメソッドを定義することができます。
これにより、その値オブジェクトが一目で、何ができて、何ができないのかを判断することができます。
値オブジェクトを採用するモチベーション
値オブジェクトを増やせば、それだけ、クラス宣言が増えていくので、プリミティブな値をうまく使ってプログラムを書くことに慣れている開発者は、抵抗を(めんどくさく)感じるかと思います。書くコードもファイルの数も多くなるからです。
最初の一歩を踏み出すためのモチベーションとして以下の4つがあります。
- 表現力を増す
- 不正な値を存在させない
- 誤った代入を防ぐ
- ロジックの散在を防ぐ
全て単純なことだが、システムを強力に保護します。
表現力を増す
例として、工業製品を挙げます。
工業製品には
- ロット番号・シリアル番号・製品番号など識別するための番号が存在します。
- 数字だけの場合もあれば、アルファベットも含まれることもあります。
プリミティブな値で表現してみます
const modelNumber = "a20421-100-1";
このmodelNumber変数が、あるメソッドの引数としていきなり登場した場合を想定してみます。
method(modelNumber: string) { 省略 }
- modelNumberがstring型だという情報しか得られない
- modelNumberが何かを調査する必要がある
値オブジェクトで表現してみます
class ModelNumber { constructor(private readonly productCode: string, private readonly branch: string, private readonly lot: string) {} output(this: ModelNumber) { return this.productCode + "-" + this.branch + "-" + this.lot; } } const modelNumber = new ModelNumber('yano','yuki','yuno').output(); console.log(modelNumber);
実行結果
[LOG]: "yano-yuki-yuno"
ModelNumberクラスの定義を確認することで、製品番号は、プロダクトコード・枝番・ロット番号で構成されていて、それぞれの間には - で区切りがあることがわかります。
値オブジェクトはプリミティブな値に比べて、自身がどんなものかを自己主張してくれます。
不正な値を存在させない
プリミティブな値にはルールを設定できないが、値オブジェクトにはルールを作ることができる。そのルールにより、不正な値を存在させないことを実現できます。
例として、ユーザ名を挙げます。
ルール:ユーザ名は3文字以上でなくてはならない
プリミティブな値
const userName = "ni"
当然ですが、これでも使えてしまうので、ルールを破れてしまいます。
この後に if文で条件分岐をすることでルールを制定することはできますが、他の場所でも同じ定義を書く必要があります。
値オブジェクト
class UserName { constructor(private readonly value: string) { if(this.value.length < 3) { throw new Error('ユーザ名は3文字以上でお願いします'); } } } const userName = new UserName('ni');
実行結果
[ERR]: "Executed JavaScript Failed:" [ERR]: ユーザ名は3文字以上でお願いします
- 2文字であることで、エラーが発生しています
- ユーザ名のルールを一箇所にまとめることができる
- ユーザ名のルールをUserNameクラスを確認することで把握できる
誤った代入を防ぐ
プリミティブな値
class User { constructor(public id: string, public name: string) {} } function createUser(name: string): User { const user = new User(name, name); return user; }
- createUserメソッドで、new Userでインスタンス化している引数にidとしてnameを渡してしまっているがエラーにならない
- string型であれば、Userクラスは引き受けてしまえるのだが、idがどんなものかはシステムによって異なる
- ユーザ名・メールアドレスなどの別の値になる場合もあるのに、string型なら通ってしまうため、誤った代入が行われる可能性がある
- string型であれば、Userクラスは引き受けてしまえるのだが、idがどんなものかはシステムによって異なる
値オブジェクト
class UserId { constructor(private readonly value: string) {} } class UserName { constructor(private readonly value: string) {} } class User { constructor(public id: UserId, public name: UserName) {} } function createUser(name: UserName): User { const user = new User(name, name); //第一引数のnameがコンパイルエラーとなる return user; }
- createUserメソッドの引数の型はUserName型であり、new Userの第一引数では、UserId型を渡す必要があるので、コンパイルエラーが出ています
- 値オブジェクトを使うことで可能な限りエラーを事前に防ぐことができます
ロジックの散在を防ぐ
ユーザ作成とユーザ更新のメソッドがあった場合に、値オブジェクトを使わないと、それぞれのメソッドに同じルールを定義する必要が出てきてしまうので、一箇所にまとめたい。
function createUser(name: string): User { if(name === null) throw new Error(); if(name.length < 3) throw new Error('ユーザ名は3文字以上です'); const user = new User(name); return user; } function updateUser(name: string): User { if(name === null) throw new Error(); if(name.length < 3) throw new Error('ユーザ名は3文字以上です'); const user = new User(name); return user; }
- 同じルールをそれぞれのメソッドに定義している
- 変更時に変更箇所が多くなる
- 間違え・変更忘れ、バグの元である
- 変更時に変更箇所が多くなる
値オブジェクトに定義する
class UserName { constructor(private readonly value: string) { if(value === null) throw new Error(); if(value.length < 3) throw new Error('ユーザ名は3文字以上です'); } } class User { constructor(public name: UserName) {} } function createUser(name: string): User { const userName = new UserName(name); const user = new User(userName); return user; } function updateUser(name: string): User { const userName = new UserName(name); const user = new User(userName); return user; }
- UserNameクラス内にルールを移動させることで、createUser updateUserメソッドそれぞれに散在していたルールを一箇所にまとめることで、問題解決した