ドメイン駆動設計の値オブジェクトとエンティティについて学んだことをまとめる(TypeScript)
『ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本』を最近読んでまして、そこで学んだことを自分なりにまとめたいと思います。ドメイン駆動設計についての知識はまだ生半可なものなので、間違いはあるかもしれません。そのときは指摘よろしくお願いします。
コードで示すときに使う言語は TypeScript
です。また、動作はユニットテストで確認します。
ドメイン駆動設計についてはここでは話しませんが、短く説明するなら、そのシステムについての知識(ドメインの知識)に焦点をあててソフトウェアを設計する手法です。
今回は、ドメイン駆動設計(以下、DDD)の基本要素(パターン)、値オブジェクトとエンティティについてまとめます。
概要
値オブジェクト
は、オブジェクトを値として表現するパターンです。DDD においては、ある概念を値オブジェクトで表現することで設計、実装がしやすくなります。
また似たようなパターンとしてエンティティ
があります。これも概念をエンティティというパターンで表現することで設計、実装がしやすくなります。
両者は似ており、違いとしては、値オブジェクトはオブジェクトが不変であること。同じかどうかは、そのオブジェクトが持つデータの値が全て同一かどうかで判断することです。エンティティは、可変であること。同じかどうかは、識別子(ID)が同一かどうかで判断します。
値オブジェクトについて
値オブジェクトとは、オブジェクト(クラスのインスタンス)を値と同じように扱うパターンです。値は、1 や 2 のような数値や "hoge" のような文字列の値のことです。このパターンは、インスタンスを値と同じように扱うということです。
値オブジェクトは以下の性質を持っています。
- インスタンスが持つデータは変更できない(不変である)
- 変数に代入が可能(交換が可能)
- 比較が可能(等価性による比較)
例えば、あるシステムに"氏名"という概念があり、それを値として表現したいとします。 コードで表すと以下のようになります。
export class FullName { constructor(private readonly firstName: string, private readonly lastName: string) {} equals(other: FullName): boolean { if (this == other) { return true; } return ( this.firstName === other.firstName && this.lastName === other.lastName ); } toString(): string { return `${this.lastName} ${this.firstName}`; } }
実際の使い方は以下のようになります。
import { expect } from "chai"; import { FullName } from "./full-name"; describe("FullName のテスト", () => { it("名前には名字、名前がある", () => { const name = new FullName("taro", "hoge"); expect(name.toString()).equal("hoge taro"); }); it("インスタンスが持つデータは変更ができない", () => { const name = new FullName("taro", "hoge"); // "プロパティ 'firstName' はプライベートで、クラス 'FullName' 内でのみアクセスできます。" というエラーが出る name.firstName = "mitsuru"; }); it("変数に代入が可能", () => { let name = new FullName("taro", "hoge"); name = new FullName("mitsuru", "hoge"); const name2 = name; expect(name2.toString()).equal("hoge mitsuru"); }); });
ふるまい
値オブジェクトにふるまい(メソッド)を持たせることで同じロジックが散らばるのを防ぐことができます。先程の FullName
の例で示すと以下のようになります。
export class FullName { constructor(private readonly firstName: string, private readonly lastName: string) {} equals(other: FullName): boolean { if (this == other) { return true; } return ( this.firstName === other.firstName && this.lastName === other.lastName ); } full(): string { return `${this.lastName} ${this.firstName}`; } }
full()
メソッドが追加されました(というか toString()
を full()
に変更しました)。このふるまいを使って氏名を文字列として取得したいとき、full()
メソッドを使えばその表現をすることができるようになります。
例えば、学生の名簿という概念があり、その概念には学生の氏名と電話番号を登録したいとします。これを値オブジェクトを使わない場合と使った場合でどう違いがあるのかを例で示します(あまり良い例ではない気がしますが)。
(名簿は英語で roster
と言うそうです。また name list
とも言うそうです)
値オブジェクトがない場合
export class Roster { // 氏名と電話番号を紐付ける private names: Map<string, string> = new Map(); get length(): number { return this.names.size; } regist(fullName: string, phoneNumber: string): void { this.names.set(fullName, phoneNumber); } // 氏名から電話番号を取得する findPhoneNumber(fullName: string): string | null { const found = this.names.get(fullName); return found ? found : null; } }
import { expect } from "chai"; import { Roster } from "./roster"; describe("Roster のテスト", () => { describe("値オブジェクトを使わない場合", () => { it("氏名から電話番号を取得する", () => { const roster = new Roster(); const fullName = "Hoge Taro"; roster.regist(fullName, "090-XXXX-XXXX"); expect(roster.findPhoneNumber(fullName)).equal("090-XXXX-XXXX"); }); }); });
roster
に氏名と電話番号を登録するときに文字列型の氏名を用意し、登録します。そのあと、同じ氏名の値を使って電話番号を取得しています。
値オブエジェクトがある場合
まず、学生の概念を表す Student
を用意します。
export class Student { constructor(private readonly firstName: string, private readonly lastName: string) {} equals(other: Student): boolean { if (this == other) { return true; } return ( this.firstName === other.firstName && this.lastName === other.lastName ); } fullName(): string { return `${this.lastName} ${this.firstName}`; } }
この Student
を使って、名簿を実装します。
import { Student } from "./student"; export class Roster { // 氏名と電話番号を紐付ける private names: Map<string, string> = new Map(); get length(): number { return this.names.size; } regist(student: Student, phoneNumber: string): void { this.names.set(student.fullName(), phoneNumber); } // 氏名から電話番号を取得する findPhoneNumber(student: Student): string | null { const found = this.names.get(student.fullName()); return found ? found : null; } }
値オブジェクトを使わない場合と同じテストを行います。
import { expect } from "chai"; import { FullName } from "./full-name"; import { Roster } from "./roster"; describe("Roster のテスト", () => { describe("値オブジェクトを使う場合", () => { it("氏名から電話番号を取得する", () => { const roster = new Roster(); const student = new Student("Taro", "Hoge"); roster.regist(student, "090-XXXX-XXXX"); expect(roster.findPhoneNumber(student)).equal("090-XXXX-XXXX"); }); }); });
学生を表す Student
が氏名の情報を持つようになったので、Student
の値を渡すだけで済むようになりました。また、値オブジェクトがない場合は、文字列を渡していたので学生以外の氏名を渡せるようになっていましたが、値オブジェクトがある場合では、Student
のみしか受け付けないため、学生以外の氏名が紛れないようになりました(コンパイルエラーとして検出される)。
さらに今回の実装では、名簿に情報として氏名を利用していますが、氏名だと同姓同名の場合に上手くいかないため仕様を変更することになり、ID を用意することになったとします。そのときは、Student
に ID の情報を追加し、Roster
からは ID を取得するように実装を変更すれば対応できます(本当は StudentId
と Student
を紐付けるような名簿にしたほうがいいのでしょうが)。つまりこれは、変更に強いということを意味します。
個人的には、値オブジェクトを使うことで間違った情報を引数に渡してしまうエラーがコンパイル時に検出できるのが良いと思っています(これは DDD に限らないオブジェクト指向を理解している人なら当たり前のことなのかもしれませんが)。
以上、値オブジェクトパターンの話でした。値オブジェクトは DDD に関係なく便利なパターンだと思います。ですが、(ドメインの)概念をよく理解せずに使っても無駄な値オブジェクトが増えてしまうことになるので、ドメインの理解をし、それをオブジェクトに適用していくことが大事なのだと思います(それが難しい)。
エンティティについて
エンティティは、値オブジェクトに似ています。でも、その性質は逆です。値オブジェクトは同じという意味は、値オブジェクトが持つデータの内容がすべて同一であれば同じです。エンティティはそうではなく、ある識別子が同じであれば同一とみなします。
そのインスタンスが生きている間に(識別子以外の)データの値が変わるような概念であれば、それはエンティティとして扱うべきです。
エンティティには以下の性質があります。
- 値の変更が可能
- 同じかどうかは識別子で判断される
値オブジェクトでは、不変でしたが、エンティティでは可変です。また値オブジェクトでは"同じ"の意味は持っているデータの値が同じであれば同じでしたが、エンティティでは、そうではありません。識別子つまり ID のことですが、これが同じであれば同じということです。
実際にコードで示すと以下のようになります。
export class Student { constructor( private readonly id: string, private firstName: string, private lastName: string ) {} equals(other: Student): boolean { if (this == other) { return true; } return this.id === other.id; } fullName(): string { return `${this.lastName} ${this.firstName}`; } }
import { expect } from 'chai'; import { Student } from './student.entity'; describe('Student のテスト', () => { it('同じかはどうかはIDで比較される', () => { const student1 = new Student('aaa', 'Taro', 'Hoge'); const student2 = new Student('bbb', 'Taro', 'Hoge'); expect(student1.equals(student2)).false; }); });
ここでは省略しますが、エンティティでも値オブジェクト同じようにふるまいを持たせることで表現力を増やします。エンティティの特徴は、生きている間は ID 以外の値が変更される可能性があるということとエンティティ同士の ID が同じであれば、同一であるとみなされることです。 例えば、人の氏名はエンティティであると言えます。これはその人が生きている間は氏名の変更が可能であり、その氏名が変わったとしてもその人自体は前の氏名と同一人物だからです。 概念にこういった性質があれば、それはエンティティとして扱うべきでしょう。
それ以外の性質、特徴については値オブジェクトと同じです。この二つは似ているけど異なるので一緒に学んで比較することでお互いを理解していくのが良いと思います。
値オブジェクトと比べると簡単になってしまいますが、以上です。
おわりに
DDD の初歩的なパターンとして値オブジェクトとエンティティを紹介しました。値オブジェクトとエンティティを初めて知ったときは少し混乱したのですが、値オブジェクトが値だと理解すればそんなに難しくはないのかなと思います。値オブジェクトがどういったものか分からなくなったら string のようなものだと覚えるといいかもしれません。string は文字列という概念を表現するための値オブジェクトです。
結局の所、パターンを使えば良いプログラミング(設計と実装)ができるようになるわけではなく、 ドメインの理解が不可欠なのでそこをサボらないように注意したいです。