このエントリーは、 「ドメイン駆動設計 #1 Advent Calendar 2018」の24日目の記事です。
23日目は、@smdmts さんの「ユビキタス言語と境界付けられたコンテキストを構築する目的とは」でした。
DDD本で触れられている、モデリングパラダイムについて考えを晒します。
モデリングパラダイム
まずはモデリングパラダイムについて考えましょう。
目的
「モデリングパラダイム」という言葉をどういう意味を持つのでしょうか。それは、以下の二つの単語で構成されます。
- モデリング
- 広義の意味での模型(モデル)を組み立てる事を言う。
- パラダイム
- ある時代のものの見方・考え方を支配する認識の枠組み。
「モデルを組み立てるための認識の枠組み」と考えてよいでしょう。
DDD本の用語解説では、以下の説明があります。モデリングの「枠組み」や「スタイル」と解釈して問題なさそうです。
ドメインにおける諸概念を切り取る特定のスタイル。ツールを組み合わされて、それらの概念に類似したソフトウェアを作成する(例えば、オブジェクト指向プログラミングや論理プログラミング)
そしてこのモデリングパラダイムに基づき、切り取ったモデルをプログラミング言語を使ってソフトウェアに反映します。この反映のしやすさというのは、プログラミング言語がどのモデリングパラダイムをサポートするかで、変わってきます。
たとえば、オブジェクトパラダイムをサポートしていない、C言語でもオブジェクト指向プログラミングは可能ですが、コードの読みやすさ・書きやすさは及第点がつきそうです。1
主要なモデリングパラダイム
そのモデリングパラダイムにはどのようなものかあるでしょうか。現在、よく知られているものは以下(丸括弧は対応する言語)。
- オブジェクトパラダイム(オブジェクト指向言語)
- 関数パラダイム(関数型言語)
- 論理パラダイム(論理型言語)
現在でも、主流となっているモデリングパラダイムは、言わずもがな「オブジェクトパラダイム(オブジェクト指向)」です。2
DDD本でも以下のように言及があります。3
現在主流となっているパラダイムはオブジェクト指向設計であり、今日の複雑なプロジェクトのほとんどはオブジェクトを使い始めている。
この頃は「オブジェクトパラダイムを使い始めている時期」だったが、今となってはオブジェクトパラダイムは広く普及して、さらに関数型などの他のパラダイムも使われることが多くなっていると思います。
なぜオブジェクトパラダイムが主流になったか
では、なぜオブジェクトパラダイムが主流のモデリングパラダイムになったのか。DDD本からいつか引用します。
オブジェクパラダイムが選ばれ理由は、技術的でもオブジェクトに関するものでもないらしい。シンプルで洗練されているのが売りだという話。
チームがオブジェクトパラダイムを選ぶ理由の多くは、技術的なものではないし、オブジェクトに本質的なものですらない。しかし、オブジェクトモデリングは、それが世に出た時から、シンプルでありながら洗練されているという絶妙なバランスを確かに保っている。
そのモデリングパラダイムが難解すぎて開発者も少ないのであれば普及もしくくなりますが、オブジェクトパラダイムではそういうことは問題ならなかったような下りがあります。
モデリングパラダイムがあまりにも難解な場合には、習得できる開発者の数が不足するために、間違った使い方をされてしまうだろう。チームにいる技術寄りでないメンバが、少なくともパラダイムの初歩を把握することができなければ、モデルを理解することもできず、ユビキタス言語が失われることになる。オブジェクト指向設計の基本は、たいていの人にとって自然なものに見えるようだ。開発者の中には、モデリングの機微を理解できない人もいるが、技術者でなくてもオブジェクトモデルの図を理解することはできる。
技術者でなくても理解できるというのはさておき…オブジェクトパラダイムが開発者にとって比較的扱いやすく役に立ったという観点は否定はできないでしょう。また、次のように、マーケットシェアの拡大によって好循環が作られたという経緯もあるようです。
今日、オブジェクトパラダイムには、成熟し広範囲で使われていることに起因する重要な利点もある。(中略) 新しい技術のほとんどは、人気のあるオブジェクト指向プラットフォームと統合する手段を提供している
また、「開発者コミュニティと設計文化そのものの成熟」が重要とも述べています。エコシステムとして成熟したということなんでしょう。パラダイムの善し悪しはさておき、マーケットとしても大きな力を持つので無視はしにくいと思います。
同様に重要なのは、開発者コミュニティと設計文化そのものの成熟である。斬新なパラダイムを採用するプロジェクトでは、その技術を熟知している開発者や、選択したパラダイムで効果的なモデルを作成した経験のある開発者を見つけられないかもしれない。妥当な時間内で開発者を教育することも現実的ではないだろう。そのパラダイムと技術を最大限活用するためのパターンがまだ固まっていないからだ。おそらく、その分野の開拓者を迎えれば有効だが、その洞察は、まだ手に入るかたちで公表されてはいない。
これは妥当な見方ですが、オブジェクトパラダイム自身に起因するものではないと考えています。DDD発刊から10年以上経った今、システムに求める要求や開発ツールなど大きく変わってしまったので、そろそろ他のパラダイムについてもどうなのか気になるところです。
他のモデリングパラダイムの可能性
さて、他のパラダイム、特に関数型などのモデリングパラダイムの可能性はどうだろうか。DDDとしては特に否定はされていません。
ドメインモデルはオブジェクトモデルでなくてもよい。例えば、Prologで実装され、そのモデルが論理ルールとファクトから構成されているモデル駆動設計もある。モデルパラダイムは、人がドメインについて考える際に好んで用いる、特定の方法に取り組むものだと考えられてきた。すると、そうしたドメインのモデルは、そのパラダイムによって形作られる。
場合によっては、混在したパラダイムを使うことがあります。たとえば、Javaのようなオブジェクト指向言語でも、関数モデルのために関数オブジェクトを部分的に導入することがあります。他にも、O/Rマッパーは表とオブジェクトの混在したパラダイムとして古くからよく知られています。多かれ少なかれ、昔からオブジェクトパラダイムを主軸にしつつも、対象のドメインによってはパラダイムを適切に選択してきたと言えます。
プロジェクトにおいて支配的なモデルパラダイムが何であれ、ドメインの中には、他のパラダイムならばはるかに容易に表現できる場所が必ずあるものだ。あるドメインに、ほんのわずか変則的な要素があるが、その他の点では、特定のパラダイムで問題なく機能するという場合、開発者は首尾一貫したモデルの中に若干扱いにくい対象があっても我慢することができる(中略)。しかし、ドメインの主要部分が、さまざまな異なるパラダイムに属していると思われる場合、各部分を適したパラダイムでモデル化することは、知的好奇心を刺激する。この場合には、実装をサポートするツール類を混ぜて使用することになる。相互依存関係が少なければ、他のパラダイムで作られたサブシステムはカプセル化することができる。複雑な数学的計算であっても、単にオブジェクトから呼び出せばよいというようなものだ。あるいは、さまざまな側面がより絡み合っていることもある。例えば、オブジェクトの相互作用が、何らかの数学的関係で決まる場合が挙げられる。
最近では、Scalaなどのマルチパラダイム言語も登場しています。Scalaはオブジェクト指向言語をベースに関数パラダイムを取り入れており、モデリング技法についてもオブジェクトと関数の両方が適用可能です。(これは冒頭にも述べましたが。)たとえば、関数パラダイムをサポートしていないオブジェクト指向言語では、関数型スタイルのコードは書けますが、コードの読みやすさ・書きやすさは問題があります。当然ですが、マルチパラダイムを前提した言語ではこういった問題を軽減して、より効率的なモデリングをサポートします。
原点はモデル駆動設計
こういった混在したパラダイムを選択するしないにしろ、拠り所はドメインモデルが示す戦略の部分です。DDD本では以下の警鐘を鳴らしています。詳しくは書籍をご覧ください。4
- 実装パラダイムと対立しないこと。
- パラダイムに合うモデルの概念を見つけること。
- UMLにこだわらないこと。
- 懐疑的であること。
いずれにしても、ツールやドキュメントに頼り切らず、なぜそのようなモデルなのかを語り合い、ソフトウェアと取り巻くあらゆる活動で中心的な価値観を作り上げることが肝要ではないかと思います。
オブジェクトパラダイムに貢献する関数型設計テクニック
ここからは毛色を変えて、DDD本の第10章のしなやかな設計から、オブジェクトと関数のパラダイムの交差点となりそうな設計テクニックを抜粋して紹介してみようと思います。
副作用のない関数(SIDE-EFFECT-FREE-FUNCTIONS)
副作用のあるメソッドを組み合わせてしまうと、どんな影響が発生するか予測できなくなるため、プログラムの振る舞いを理解することが極端に難しくなります(内部実装を理解しなければ扱えないというのもこれが原因の一つ)。副作用のない関数同士ならば、組み合わせて使っても何が起こるか予測できなくなることがありません。また、テストも容易になります。これが、副作用のない関数(SIDE-EFFECT-FREE-FUNCTIONS)ですこの原則は、当然ドメインロジックにも適用可能です。
次の例は、DDD本に登場する、絵の具オブジェクト(Paint)の例です(普段はScalaでコード例を書くが今日はGoで書いてみよう)。二つの絵の具を混ぜるMixInメソッドの実装をみてください。このメソッドは戻り値を返さず内部状態を更新する可変メソッドであり、絵の具オブジェクト(Paint)も同一インスタンスで複数の状態へ遷移する可変オブジェクトです。書籍では顔料部分を副作用を起こさない別の値オブジェクトをに切り出し、絵の具オブジェクト(Paint)と組み合わせる方法が提案されています。
// 絵の具を表すオブジェクト(副作用が伴う可変オブジェクト)
type Paint struct {
volume int // 絵の具の量を表す
red int // red, yellow, blueは絵の具の顔料を表す
yellow int
blue int
}
func NewPaint(volume int, red int, yellow int, blue int) *Paint {
return &Paint{ volume, red, yellow, blue }
}
func (p *Paint) GetVolume() int { return p.volume }
func (p *Paint) GetRed() int { return p.red }
func (p *Paint) GetYellow() int { return p.yellow }
func (p *Paint) GetBlue() int { return p.blue }
func (p *Paint) String() string {
return fmt.Sprintf("[volume = %d, red = %d, yellow = %d, blue = %d]", p.volume, p.red, p.yellow, p.blue)
}
// 絵の具を混ぜ合わせる(破壊的操作)
func (p *Paint) MixIn(other *Paint) {
p.volume += other.volume
ratio := float64(other.volume) / float64(p.volume)
// 以下、顔料の更新処理
p.red = int(float64(p.red+other.red) / ratio)
p.yellow = int(float64(p.yellow+other.yellow) / ratio)
p.blue = int(float64(p.blue+other.blue) / ratio)
}
func TestPaint(t *testing.T) {
p1 := NewPaint(100, 5, 10, 15)
p2 := NewPaint(100, 15, 15, 15)
fmt.Printf("p1 = %v\n", p1) // p1 = [volume = 100, red = 5, yellow = 10, blue = 15]
fmt.Printf("p2 = %v\n", p2) // p2 = [volume = 100, red = 15, yellow = 15, blue = 15]
p1.MixIn(p2)
fmt.Printf("p1 = %v\n", p1) // p1 = [volume = 200, red = 40, yellow = 50, blue = 60]
}
次の例は、顔料オブジェクト(PigmentColor)を導入した例です。絵の具オブジェクト(Paint)は顔料オブジェクト(PigmentColor)に顔料に関する責任を委譲しているため、先の例と違って顔料の混ぜ合わせメソッドも顔料オブジェクト(PigmentColor)に移動しています。
// 絵の具を表すオブジェクト(副作用が伴う可変オブジェクト)
type Paint struct {
volume int
pigmentColor *PigmentColor
}
func NewPaint(volume int, pigmentColor *PigmentColor) *Paint {
return &Paint{ volume, pigmentColor }
}
func (p *Paint) GetVolume() int { return p.volume }
func (p *Paint) GetPigmentColor() *PigmentColor { return p.pigmentColor }
func (p *Paint) String() string {
return fmt.Sprintf("[volumen = %d, pigmentColor = %v]", p.volume, p.pigmentColor)
}
// 絵の具を混ぜ合わせる(破壊的操作)
func (p *Paint) MixIn(other *Paint) {
p.volume += other.volume
ratio := float64(other.volume) / float64(p.volume)
// 顔料の更新処理を関数化して副作用を起こさないにし、関数で得た新しいpigmentColorを入れ替える処理をmixInメソッドで行う。
p.pigmentColor = p.pigmentColor.MixedWith(other.pigmentColor, ratio)
}
// 顔料を表すバリューオブジェクト(副作用が伴わない不変オブジェクト)
type PigmentColor struct {
red int
yellow int
blue int
}
func NewPigmentColor(red int, yellow int, blue int) *PigmentColor {
return &PigmentColor{red, yellow, blue}
}
func (p *PigmentColor) GetRed() int { return p.red }
func (p *PigmentColor) GetYellow() int { return p.yellow }
func (p *PigmentColor) GetBlue() int { return p.blue }
func (p *PigmentColor) String() string {
return fmt.Sprintf("[red = %d, yellow = %d, blue = %d]", p.red, p.yellow, p.blue)
}
// 顔料を混ぜあわせて新たな顔料を生成して返す。副作用は起こさない関数として実装する。(非破壊的操作)
func (p *PigmentColor) MixedWith(other *PigmentColor, ratio float64) *PigmentColor {
red := (int)(float64(p.red+other.red) / ratio)
yellow := (int)(float64(p.yellow+other.yellow) / ratio)
blue := (int)(float64(p.blue+other.blue) / ratio)
return NewPigmentColor(red, yellow, blue)
}
func TestPaint(t *testing.T) {
p1 := NewPaint(100, NewPigmentColor(5, 10, 15))
p2 := NewPaint(100, NewPigmentColor(15, 15, 15))
fmt.Printf("p1 = %v\n", p1) // p1 = [volume = 100, red = 5, yellow = 10, blue = 15]
fmt.Printf("p2 = %v\n", p2) // p2 = [volume = 100, red = 15, yellow = 15, blue = 15]
p1.MixIn(p2)
fmt.Printf("p1 = %v\n", p1) // p1 = [volume = 200, red = 40, yellow = 50, blue = 60]
}
先の例と比較して、顔料オブジェクト(PigmentColor)の混ぜ合わせメソッドMixInはthisを破壊せずに、新たな状態を持つインスタンスを返します。また、不変性を持つ顔料オブジェクト(PigmentColor)を可変性を持つ絵の具オブジェクト(Paint)に組み合わせる際は、pigmentColorフィールドの値を新しい顔料オブジェクト(PigmentColor)で上書きすることになります。
つまり、副作用が伴うロジックを、副作用が伴わない(問合せとしての)関数と、副作用を伴う命令を分けて用いることで、副作用を安全に局所化することができるわけです。これは関数の持つ一つの特性を利用したに過ぎませんが、設計は理解しやすく、テストもしやすく、安全に扱うことにができ、他の操作と組み合わせやすくなります。こういった観点で設計をサポートすることによって、このクラスの利用者はインターフェイス仕様だけを理解すればよく、内部実装まで理解する必要はありません。
閉じた操作(CLOSURES OF OPERATIONS)
閉じた操作は、数学的性質5を利用した、モジュールの凝集度を高めるためのパターンです。
たとえば、実数の加算は実数の集合の下で閉じているという表現をします。
1 + 1 = 2
プログラム上でも以下のような操作をよく行いますが、対象となる操作(メソッド)は加算だけに限った話ではありません。
StringC = StringA + StringB
ListC = ListA + ListB
MoneyC = MoneyA + MoneyB
MoneyC = MoneyA - MoneyB
この閉じたメソッドのシグニチャには、それを所有するクラス以外に依存しない、つまり同じ型に閉じているというところに注目してください。
具体的なコード表現に落とすと以下のようになるでしょう。実装する際は、戻り値の型が引数の型と同じになるようにします。
type Money struct {
amount int
currency string
}
func (m *Money) GetAmount() int { return m.amount }
func (m *Money) GetCurrency() string { return m.currency }
func NewMoney(amount int, currency string) *Money {
return &Money{ amount, currency }
}
// 閉じた操作としての実装したAddメソッド
func (m *Money) Add(other *Money) (*Money, error) {
if m.currency != other.currency {
return nil, fmt.Errorf("Invalid currency: %v", other)
}
return NewMoney(m.amount + other.amount, m.currency), nil
}
このパターンでは、余計な概念を混入させずに、モジュール内にある知識だけで対象のドメインを理解できるようになります。これが高凝集をサポートする理由です。
宣言的な設計
処理方法ではなく対象の性質などを宣言することで設計(宣言的な設計)するスタイルを、DDDでは「宣言的スタイル」と呼んでいます。そのアプローチには、以下の3つがあると紹介している。それぞれPros/Consはあるのですが、中でもDSLのアプローチに優位性を見出しているようです。詳しくは10章の宣言的な設計を読んでみてください。
- コード生成
- モデルの特徴を宣言することえ動作するプログラムを生成するアプローチ。
- ルールベース
- ルールを定義する言語を使うアプローチ
- ドメイン特化言語(DSL=domain-specific language)
- 特定のドメイン向けに設計されたコンピュータ言語
ドメイン特化言語
宣言的な設計がよく利用される例に、ドメイン特化言語(DSL)があります。DSLには、プログラミング言語内部で利用できる内部DSLと、プログラミング言語の外で利用する外部DSLがあります。DSLを使うと表現力が豊かでユビキタス言語との結びつきも強くできると評価している。
こういった、HowとWhatの分離は関数型のカルチャーに多く見られます。以下は、言語内DSLの一例で、Freeモナド6を使ったリポジトリの実装を示したものです(すまぬ…。ここからScalaだ…)。今回はリポジトリが対象ですが、特定の業務でもよいでしょう。
// DSLによって表現されたFreeのインスタンス
val program: Free[UserRepositoryDSL, UserAccount] = for {
_ <- UserAccountRepository.store(userAccount)
result <- UserAccountRepository.resolveById(userAccount.id)
} yield result
// Freeのインスタンスを評価する
val evalResult: ReaderT[Task, DBSession, UserAccount] = userAccountInterpreter.run(program)
// DBSessionを与えて実行する
val resulFuture: Future[UserAccount] = evalResult.run(AutoSession).runAsync
Freeに適用するDSLはまさに自由です。FreeのDSLを使って命令(What)を記述しただけでは何もできません。
sealed trait UserRepositoryDSL[A]
case class ResolveMulti(ids: Seq[UserAccountId]) extends UserRepositoryDSL[Seq[UserAccount]]
case class ResolveById(ids: UserAccountId) extends UserRepositoryDSL[UserAccount]
case class Store(userAccount: UserAccount) extends UserRepositoryDSL[Long]
case class StoreMulti(userAccounts: Seq[UserAccount]) extends UserRepositoryDSL[Long]
case class SoftDelete(id: UserAccountId) extends UserRepositoryDSL[Long]
case class SoftDeleteMulti(ids: Seq[UserAccountId]) extends UserRepositoryDSL[Long]
// type ByFree[A] = Free[UserRepositoryDSL, A]
object UserAccountRepositoryByFree extends UserAccountRepository[ByFree] {
override def resolveById(id: UserAccountId): ByFree[UserAccount] = liftF(ResolveById(id))
override def resolveMulti(ids: Seq[UserAccountId]): ByFree[Seq[UserAccount]] = liftF(ResolveMulti(ids))
override def store(aggregate: UserAccount): ByFree[Long] = liftF(Store(aggregate))
override def storeMulti(aggregates: Seq[UserAccount]): ByFree[Long] =
liftF(StoreMulti(aggregates))
override def softDelete(id: UserAccountId): ByFree[Long] = liftF(SoftDelete(id))
override def softDeleteMulti(ids: Seq[UserAccountId]): ByFree[Long] = liftF(SoftDeleteMulti(ids))
}
命令を具体的にどのように処理するか(How)は、構造的に分離されたインタプリターが担います。
class UserAccountInterpreter[M[_]](...) {
private val dao: UserAccountDao[M] = ...
private def interpreter: UserRepositoryDSL ~> M = new (UserRepositoryDSL
~> M) {
override def apply[A](fa: UserRepositoryDSL[A]): M[A] = fa match {
case ResolveById(id) =>
// daoを使った処理
case ResolveMulti(ids) =>
// daoを使った処理
case Store(aggregate) =>
// daoを使った処理
case StoreMulti(aggregates) =>
// daoを使った処理
case SoftDelete(id) =>
// daoを使った処理
case SoftDeleteMulti(ids) =>
// daoを使った処理
}
}
def run[M[_]: Monad, A](program: ByFree[A]): M[A] =
program.foldMap(interpreter)
}
それなりに高度な技術と設計概念を具現化する能力が求められるのは確かですが、DSLによって表現力が向上するのは魅力的かもしれません。
注意
本題と少し逸れますが、この例では、GroupRepositoryなどの別の型のDSLと合成する場合、どうしたらいいかは触れていません。この実装のままでは合成できません。InjectKやTagless Finalを使う方法があります。興味あれば以下のリンクを参照してみてください。
https://typelevel.org/cats/datatypes/freemonad.html#composing-free-monads-adts
https://qiita.com/yyu/items/377513f17fec536b562e
まとめ
主軸となるオブジェクトパラダイムを活用しながらも、関数型などの他のパラダイムから導入できる設計パターンが数多くあるので、参考にする価値があると思います。以上!
明日は @kimutyam さんです!
-
逆に、Go言語は、一般的なオブジェクト指向言語ではない(「クラス」と「継承」がないからとか、例外もないとか)とよく言われますが、「インターフェイス」と「メソッド」によって最小限のオブジェクトパラダイムをサポートするので、C言語での曲芸のようなオブジェクトコードより、見慣れたオブジェクト表現が可能です。オブジェクト指向の定義にもよりますが、必ずしも特定の言語機能さえあれば達成できるということでもないようです。 ↩
-
オブジェクト指向の方式(クラスベース, プロトタイプベース、Mixinなど)について個々では問いません。オブジェクト同士の相互作用として、システムの振る舞いをとらえる考え方と捉えてください。 ↩
-
第二部 モデリングパラダイムからの引用 ↩
-
第二部 パラダイムを混在させる際にはモデル駆動設計に忠実であること ↩
-
半群(Semigroup)の性質を利用しているのでしょう。たぶん。 ↩