TypeScriptにおけるDDDのドメインオブジェクトの課題と対策
こんにちは、近藤です。
commmune Advent Calendar 2023 18日目の記事は『TypeScriptにおけるDDDのドメインオブジェクトの課題と対策』です
はじめに
ドメイン駆動設計(DDD)は、複雑なビジネスロジックを扱うアプリケーション開発において、重要かつ効果的なアプローチとして広く認識されています。
コミューンでは、現場で役立つシステム設計の原則の著者、増田さんのご協力を得て、プロダクト開発を進めています。
幸運なことに私は増田さんとの密なコミュニケーションを取らせて頂いており、DDDの理論と実践方法に関する貴重な知見を深めその有用性を感じております。
しかし、TypeScriptのような構造的型付けを採用する言語でDDDを適用する際には、特有の課題が生じることがあります。本記事では、TypeScriptでの構造的型付けに伴う課題、そしてそれらを克服する方法について解説します。
なお、この記事で紹介する方法は、私個人が開発を行う際に特に便利だと感じているものであり、コミューンの実装とは異なることをご了承ください。
TypeScriptの型システムにおけるDDDの課題
TypeScriptはJavaScriptに静的型付けを加えた言語であり、その型システムは「構造的型付け」を採用しています。[1]このアプローチでは、オブジェクトの構造(プロパティやメソッドのシグネチャ)が型の決定に重要であり、型の名前はそれほど重視されません。この柔軟性は多くの場合に有益ですが、DDDの文脈では異なるドメインオブジェクトを明確に区別するのが難しいという課題が生じます。
Javaとの型システムの違い
DDDのドキュメントを参照する際に多く使用されている言語はJavaが多いように感じます。
Javaにおいては、型の互換性は型の名前に基づいて判断されます。[2]これにより、同じフィールドを持つ異なるクラスは、それぞれ異なる型として扱われます。これに対して、TypeScriptでは構造が同じであれば、異なるクラスも同じ型とみなされることがあります。
コード例
以下のTypeScriptのコード例を考えてみましょう。
*TypeScriptのコード例は、DDDのエンティティを模倣したものですが、説明を簡潔にするためにvalue objectなどの詳細は意図的に簡略化しています。
class HogeHoge {
readonly a: string
readonly b: string
constructor(a: string, b: string) {
this.a = a
this.b = b
}
}
class HugaHuga {
readonly a: string
readonly b: string
constructor(a: string, b: string) {
this.a = a
this.b = b
}
}
const testFunc = (hogehoge: HogeHoge) => { // HogeHogeを型指定
console.log(hogehoge)
}
testFunc(new HogeHoge('a', 'b'))
testFunc(new HugaHuga('a', 'b')) //typescriptエラーが起きない
このような場合、testFunc(new HugaHuga('a', 'b'))はエラーが出て欲しいですが、構造的型付け言語であるため、構造が同じものは同質と捉えられてしまいます。
同様のコードがJavaで記述された場合、クラスの構造が同じであっても、異なるクラスは異なる型として扱われます。このため、JavaではTypeScriptの例で見られるような問題は発生しません。
解決方法
以下のTypeScriptのコード例では、Symbolを使用して、異なるクラスにユニークな識別子を割り当てています。これにより、Javaのような名目的型付け言語の挙動に近い型の区別が可能になります。
JavaScriptおよびTypeScriptにおけるSymbolは、ES6で導入された新しいプリミティブ型で、ユニークな値を生成し、オブジェクトのプロパティキーとして使用することができます。[3]各Symbol値は他のどのSymbol値とも異なり、オブジェクトのプロパティにユニークな識別子を提供するのに適しています。
コード例
以下のTypeScriptのコード例では、Symbolを使用して、異なるクラスにユニークな識別子を割り当てています。これにより、Javaのような名目的型付け言語の挙動に近い型の区別が可能になります。
const symbolHogeHoge = Symbol('hogehoge')
class HogeHoge {
readonly symbol: typeof symbolHogeHoge = symbolHogeHoge //型推論に頼るとsymbolが適用されるのでtypeofで厳密に規定
readonly a: string
readonly b: string
constructor(a: string, b: string) {
this.a = a
this.b = b
}
}
const symbolHugaHuga = Symbol('hugahuga')
class HugaHuga {
readonly symbol: typeof symbolHugaHuga = symbolHugaHuga
readonly a: string
readonly b: string
constructor(a: string, b: string) {
this.a = a
this.b = b
}
}
testFunc(new HogeHoge('a', 'b'))
testFunc(new HugaHuga('a', 'b')) //typescriptエラーが起きる
このコードでは、HogeHogeとHugaHugaは構造的に似ていますが、異なるSymbolを持っているため、TypeScriptはこれらを異なる型として扱います。これにより、型の安全性が向上し、DDDの文脈での明確なドメインオブジェクトの区別が可能になります。
Symbolを使用することのメリット
文字列を使用しても同様の効果が得られるかもしれませんが、Symbolの使用には特有のメリットがあります。例えば、以下のようなケースを考えてみましょう。
const symbolHogeHoge = 'hogehoge'
class HogeHoge {
readonly symbol: typeof symbolHogeHoge = symbolHogeHoge
readonly a: string
readonly b: string
constructor(a: string, b: string) {
this.a = a
this.b = b
}
}
const symbolHogeHoge2 = 'hogehoge'
class HogeHoge2 {
readonly symbol: typeof symbolHogeHoge2 = symbolHogeHoge2
readonly a: string
readonly b: string
constructor(a: string, b: string) {
this.symbol = symbolHogeHoge2
this.a = a
this.b = b
}
}
const testFunc = (hoge: HogeHoge) => {
console.log(hoge)
}
testFunc(new HogeHoge('a', 'b'))
testFunc(new HogeHoge2('a', 'b'))//typescriptエラーが起きない
文字列を使用した場合、異なるクラスでも同じ文字列を使用しているため、TypeScriptはこれらを同じ型として扱います。しかし、Symbolを利用すると、同じ文字列を使用しても異なるSymbolが生成されます。
const symbolHogeHoge = Symbol('hogehoge') //同じ文字列のsymbol
class HogeHoge {
readonly symbol: typeof symbolHogeHoge = symbolHogeHoge
readonly a: string
readonly b: string
constructor(a: string, b: string) {
this.a = a
this.b = b
}
}
const symbolHogeHoge2 = Symbol('hogehoge') //同じ文字列のsymobol
class HogeHoge2 {
readonly symbol: typeof symbolHogeHoge2 = symbolHogeHoge2
readonly a: string
readonly b: string
constructor(a: string, b: string) {
this.symbol = symbolHogeHoge2
this.a = a
this.b = b
}
}
const testFunc = (hoge: HogeHoge) => {
console.log(hoge)
}
testFunc(new HogeHoge('a', 'b'))
testFunc(new HogeHoge2('a', 'b'))//typescriptエラーが起きる
Javaでは、パッケージを使用して名前空間を管理します。これにより、異なるパッケージ内に同じ名前のクラスが存在しても、それぞれ異なるクラスとして扱われます。[4]一方で、TypeScript(およびJavaScript)では、モジュールシステムが名前空間の管理に用いられます。異なるモジュール内で同じ名前のクラスや関数を定義しても問題なく、これらはモジュールのスコープによって区別されます。
私はドメインオブジェクトはユニークな名前を持つことが望ましいと考えていますが、設計によっては同様の名前を使用するケースもあると思います。
そのような場合でも上記の実装であれば、異なるドメインオブジェクトとして取り扱うことが可能になります。
注意点: TypeScriptの型システムとランタイムの挙動
TypeScriptの型システムはコンパイル時にのみ機能します。TypeScriptはJavaScriptにコンパイルされた後、ブラウザやNode.jsなどのJavaScript環境で実行されます。このプロセス中に、TypeScriptの型情報は削除され、実行時には型チェックが行われません。[5]
したがって、今回紹介したコード例では、エディター上では型エラーが発生しますが、ランタイムではエラーは発生しません。これはTypeScriptの運命でもあり、実行時の型安全性を確保するためには、追加のランタイムチェック(例えば、型ガードやアサーション)を実装する必要があります。
ただし、実行時の型チェックを行うために多大な労力を要する場合、使用する言語を変更することも一つの選択肢です。TypeScriptは開発時の型安全性とコードの可読性を高めるために優れていますが、実行時の型安全性に関してはJavaScriptの制約を受けるため、この点を理解し、適切な用途で使用することが重要です。
終わりに
この記事では、TypeScriptにおけるドメイン駆動設計(DDD)の適用に際して直面する構造的型付けに関連する課題と、それらを解決するためのアプローチを検討しました。特に、Symbolを利用した解決策は、型の安全性を向上させ、異なるドメインオブジェクト間での明確な区別を可能にする有効な手段です。個人的には、この方法を非常に気に入っており、使い勝手も良いと感じています。
しかし、DDDのドメインオブジェクトの文脈において以上のアプローチをとっている記事などは見当たりませんでした。私が個人開発でこの方法を用いている中で、非常に有効だと感じていますが、十分な検証はまだされていないと考えています。もし、この方法に関する問題や課題があれば、ぜひ共有していただきたいと思います。
また、私のキャリアはPythonとJavaScript/TypeScriptに限られており、Javaに関する知識は主に公式ドキュメントや他の資料から得たものです。もしJavaに関して誤った認識をしている部分があれば、ご指摘いただけると大変ありがたいです。
記事にするにあたり、改めてドキュメントを読む機会になり、非常に勉強になりました。
この記事が、TypeScriptでDDDを実践する際の一助となれば幸いです。皆様からのフィードバックや知見の共有をお待ちしております。
Discussion
一般的には Branded Type が使われているところだと思います。
この辺が網羅的ですかね?
あと symbol を型だけで使う方法もあります
acomaguさん
TypeScriptでNominal Typingを実現する方法に関するご紹介、ありがとうございます。興味深く読ませていただきました。
特にこちらで紹介されいていたSymbolを利用した方法は、私が目指している解決策に非常に近いと感じます。
この記事と他のアプローチとを対比することで、TypeScriptのNominal Typingに関する選択肢がより明確になると思いました。
貴重な情報を共有していただき、重ねて感謝申し上げます。