TypeScriptで値オブジェクトを表現する
TypeScriptでDDDの値オブジェクトを表現する方法を模索してみました。
Table of Contents
DDDについて
DDDとは
DDDとはドメイン駆動設計(Domain-driven design)の略です。
以下はWikipediaから引用した定義です。
ソフトウェアの設計手法であり、「複雑なドメインの設計は、モデルベースで行うべき」であり、また「大半のソフトウェアプロジェクトでは、システムを実装するための特定の技術ではなく、ドメインそのものとドメインのロジックに焦点を置くべき」であるとする
難しいことのように思えますが、誤解を恐れずに言うと
コードを見るだけでビジネスロジックが分かるようにプロダクトを作ろう
という設計思想です。
DDDに関する書籍
この3ヶ月間、私は3つのプロダクトでDDDを試しました。
この記事では触れませんが、ほとんどのケースでDDDは素晴らしい価値を提供してくれました。
そんなDDDを始めるきっかけとなった2つの本を紹介します。
エリック・エヴァンスのドメイン駆動設計
一番有名と思われるエリック・エヴァンスの本です。
通称エヴァンス本と呼ばれています。
ページ数も多く、お値段もビッグです😏
内容は素晴らしいです.. しかし、難しすぎます😓
以下のすべてを満たさない方は躓く可能性が高いです。
1つも条件を満たさない方へは読むこと自体をオススメしません..。
- オブジェクト指向を理解している
- 本の中で例に出されているドメインの知識がある
- 設計をしたことがある
- 同じシステムを1年以上開発し続けたことがある
ドメイン駆動設計入門
私の周りではボトムアップDDD本と呼ばれています。
エヴァンス本に挫折した方のために現れた救世主だと私は思っています😄
本書が発売されたのは数日前であり、まだ読み切っていません。
ただ、著者nrsさんが運営されているサイトにはお世話になりました。
上記サイトやスライドがなければ、チームメンバへDDDを広めることはできなかったと思います。
値オブジェクトとは
値オブジェクトはDDDに登場する用語です。
以下の性質を持つものが値オブジェクトである、と私は理解しています。
- Immutable (不変)
- 全ての要素が等しい場合のみ等しい
- 不完全なオブジェクトが存在できない
値オブジェクトの定義について
値オブジェクトの詳細については割愛します。
nrsさんの以下がオススメですので、理解を深めたい方は是非読んでみてください😄
AbstractValueObjectクラスの実装
クラスを使って、実際に値オブジェクトを実装してみます。
TypeScriptのバージョンは3.7.5です。
まずは全てのベースとなるAbstractValueObject
クラスを作成します。
import { shallowEqual } from 'shallow-equal-object';
export abstract class AbstractValueObject<T> {
protected readonly _value: T;
protected constructor(_value: T) {
this._value = Object.freeze(_value);
}
equals(vo?: AbstractValueObject<T>): boolean {
if (vo == null) {
return false;
}
return shallowEqual(this._value, vo._value);
}
}
この抽象クラスは値オブジェクトがもつ2つの性質を実現しています。
Immutableの実現
唯一のプロパティ_value
をObject.freeze
することで変更不可能にしています。
protected readonly _value: T;
protected constructor(_value: T) {
this._value = Object.freeze(_value);
}
Object.freeze
はネストしたプロパティには効果がありません。
完全に変更不可能にしたい場合は、Primitiveでない直下のプロパティを値オブジェクトにする必要があります。
全ての要素が等しい場合のみ等しくする
値オブジェクトの等価判定でよく使う処理です。
直下のプロパティ同士が完全に等価である場合のみtrue
を返します。
equals(vo?: AbstractValueObject<T>): boolean {
if (vo == null) {
return false;
}
return shallowEqual(this._value, vo._value);
}
shallowEqual
は以下のpackageを利用しています。
_valueやコンストラクタがprotectedなのはなぜ?
protected readonly _value: T;
protected constructor(_value: T) {
this._value = Object.freeze(_value);
}
値のアクセスはgetterを、インスタンス生成にはStatic factory methodを利用してもらうためです。
ここでは詳しく触れませんが、オブジェクト指向のカプセル化によるメリットを得るためです。
ValueObjectクラスの実装
AbstractValueObject
クラスを継承して、もう少し具体的なValueObject
クラスを作ります。
interface ValueObjectProps {
[index: string]: any;
}
export abstract class ValueObject<T extends ValueObjectProps> extends AbstractValueObject<T> {}
T
は値オブジェクトがもつ[index: string]: any
型を継承したプロパティです。
以下のようにクラスを作成します。
interface UserProps {
id: number;
name: string;
}
class User extends ValueObject<UserProps> {
static create(props: UserProps): User {
return new User(props);
}
get name(): string {
return this._value.name;
}
}
使用例です。
const ichiro = User.create({ id: 1, name: 'hoge' });
const ichiro2 = User.create({ id: 1, name: 'hoge' });
console.log(ichiro.name); // hoge
console.log(ichiro == ichiro2); // false
console.log(ichiro === ichiro2); // false
console.log(ichiro.equals(ichiro2)); // true
createの引数がpropsである理由
2つ理由があります。
- 実装ミスのリスクが減る
- 仕様の変更に強い
実装ミスのリスクが減る
User.create(id: string, firstName: string, lastName?: string)
という前提で話をします。
TypeScriptは名前付き引数に未対応のため、以下のようには書けません。
User.create(id="100", firstName="Taro", lastName="Suzuki")
それゆえ、以下のような実装ミスをしても気付きにくいという問題があります。
User.create("100", "Suzuki", "Taro")
User.create("Taro", "Suzuki", "100")
Propsによる型付きの指定は、このリスクを最小限にできます。
User.create({
id: "100",
firstName: "Taro",
lastName: "Suzuki",
})
仕様の変更に強い
User.create
に新しく必須パラメータが追加されることを想像してみてください
User.create(id: string, firstName: string, lastName?: string, required: string)
TypeScriptでこの書き方は不正です。
Optionalな引数lastName?: string
のあとに、Requiredな引数required: string
を指定することはできません。
その場合、引数の順序を変更する必要があります。
User.create(id: string, required: string, firstName: string, lastName?: string)
でも待って下さい!!
このコードは修正ミスのリスクを格段に引き上げます。
なぜなら、以下の様な既存コードがエラーなしに通ってしまうからです。
User.create("100", "Taro", "Suzuki")
Props指定の場合、引数の順序は関係ないのでこの問題は起きません。
User.create({
id: "100",
lastName: "Suzuki",
firstName: "Taro",
})
しかも、UserProps
型にrequired
プロパティを追加されると、上記はエラーになります。
それに気付いて、以下のように修正できるわけです😄
User.create({
id: "100",
lastName: "Suzuki",
firstName: "Taro",
required: "ok",
})
不完全なオブジェクトが存在できないようにする
不完全であるかの判定は、実装する値オブジェクトクラスによって決まります。
そのため、抽象クラスではなく具象クラスでcreateするときにValidationします。
static create(props: UserProps): User {
if (!(props.id > 0)) {
throw new Error('idは1以上を指定してください');
}
if (!(props.name.length > 0)) {
throw new Error('nameは1文字以上指定してください');
}
return new User(props);
}
createで値オブジェクトを作成するときに、不正な条件下ではErrorが送出されます。
const ichiro = User.create({ id: 1, name: '' }); // Error: nameは1文字以上指定してください
const ichiro2 = User.create({ id: -1, name: 'hoge' }); // Error: idは1以上を指定してください
const ichiro3 = User.create({ id: 2, name: 'jiro' }); // ok
システムを停止するのか、catchしてエラーを表示するのかは状況によります。
PrimitiveValueObjectクラスの実装
primitiveな値を値オブジェクトとして扱いたい場合のためPrimitiveValueObject
を作ります。
export abstract class PrimitiveValueObject<T> extends AbstractValueObject<T> {
get value(): T {
return this._value;
}
}
生成時のValidationが主な目的なので、getterで値を取り出す共通実装をしておきます。
必要があればgetterを追加すればOKです。
PrimitiveValueObject
を使って、User
クラスのid
とname
を値オブジェクトにした実装が以下です。
class UserId extends PrimitiveValueObject<number> {
static create(value: number): UserId {
if (!(value > 0)) {
throw new Error('idは1以上を指定してください');
}
return new UserId(value);
}
}
class UserName extends PrimitiveValueObject<string> {
static create(value: string): UserName {
if (!(value.length > 0)) {
throw new Error('nameは1文字以上指定してください');
}
return new UserName(value);
}
}
interface UserProps {
id: UserId;
name: UserName;
}
class User extends ValueObject<UserProps> {
static create(props: UserProps): User {
return new User(props);
}
get name(): string {
return this._value.name.value;
}
}
const ichiro = User.create({ id: UserId.create(2), name: UserName.create('jiro') });
console.log(ichiro.name); // jiro
コード量は増えてしまいましたが、インタフェースはUser.create
の引数が値オブジェクトに変わっただけです。
Validationの役割も的確なスコープに留まっています。
createの引数とプロパティの型を分ける
先のコードではUser.create
のインタフェース(引数)が少し変わっていました。
実はこれを全く変えずに実装を変更する方法があります。
User.create
の引数をUserProps
ではなく、新たに作ったUserArgs
で分離してしまうのです。
interface UserProps {
id: UserId;
name: UserName;
}
interface UserArgs {
id: number;
name: string;
}
class User extends ValueObject<UserProps> {
static create(args: UserArgs): User {
return new User({
id: UserId.create(args.id),
name: UserName.create(args.name),
});
}
get name(): string {
return this._value.name.value;
}
}
const ichiro = User.create({ id: 2, name: 'jiro' });
console.log(ichiro.name); // jiro
User.create
のコード量と引き替えに、生成ロジックと内部状態の分離がされました。
使う側からすると、個々の値オブジェクトを作成する手間が省けるのは嬉しいですね😄
総括
TypeScriptでDDDの値オブジェクトを表現する方法を模索した結果、以下3つのクラスを定義して使うことにしました。
AbstractValueObject
ValueObject
PrimitiveValueObject
システムが小さいうちは、回りくどく面倒に感じるかもしれません。
しかし、ある程度の規模になってくると値オブジェクトが厳格に定義されているメリットを感じられることでしょう😄
getter
が不要であれば、公称型と生成関数を使ったアプローチもオススメです。
公式でも利用されている手法なので、小規模プロダクトならアリだと思います👍
最後に、本記事で紹介したクラスの2020-02-19現在におけるコードへのリンクを貼っておきます。