TypeScript でエフェクトを使う話の続き. あるいは DI 手法の話でエフェクトを使うのを半ば冗談として書いていたのを, より具体的な状況を想定してもう少し真面目に考えてみる.
ドメイン層と永続化
ドメイン層においては永続化のための具体的な技術については関心を持ちませんが, 永続化すること自体に関心を持たないわけではありません. リポジトリというドメイン層に用意されたインターフェースを使って保存や検索を行いつつ, 具体的な技術はドメイン層の外にあるインターフェースの実装に任せるというのが典型例です. これは永続化以外にも, 例えばイベントの送信などについても同様のことが言えます. (なぜドメイン層では具体的な技術に関心を持たないかというと, ドメインが先で技術が後 (ドメイン駆動) という依存の方向を強制したいからです.)
具体例として, ツイート (†2023) を投稿するメソッドを考えてみましょう.
オブジェクトは以下のようなものが登場します.
// ツイート type Tweet = { id: string; text: string; postedAt: Date; }; // ツイート投稿時の入力 type PostTweetInput = { text: string; }; // ツイートが投稿されたことを知らせるイベント type TweetPostedEvent = { type: "tweetPosted", tweetId: string; text: string; postedAt: Date; };
まずは素朴にツイートの投稿処理 (ツイートを保存し, イベントを送信する) を実装してみると, 例えば以下のようになると思います.
async function postTweet(input: PostTweetInput): Promise<void> { // ツイートを保存 const tweet: Tweet = { id: idGenerator.next(), text: input.text, postedAt: clock.now(), }; await db.exec( "INSERT INTO tweets (id, text, posted_at) VALUES (?, ?, ?)", [tweet.id, tweet.text, tweet.postedAt] ); // イベントを送信 const event: TweetPostedEvent = { type: "tweetPosted", tweetId: tweet.id, text: tweet.text, postedAt: tweet.postedAt, }; await redis.publish("events", JSON.stringify(event)); }
これは RDB や Redis といった具体的な技術に依存してしまっているのであまりよくありません.
ということでドメインモデルとしてはより抽象的な TweetStore
, EventStream
といったインターフェースを考えて, 具体的な技術のことは取り扱わないことにします.
// インターフェースの定義は省略 async function postTweet(input: PostTweetInput): Promise<void> { // ツイートを保存 const tweet: Tweet = { id: idGenerator.next(), text: input.text, postedAt: clock.now(), }; await tweetStore.insert(tweet); // イベントを送信 const event: TweetPostedEvent = { type: "tweetPosted", tweetId: tweet.id, text: tweet.text, postedAt: tweet.postedAt, }; await eventStream.publish(event); }
実際にプログラムを実行するときには, tweetStore
などに具体的な実装を注入 (DI) して実行することになります.
この DI については様々な方法がありますが, DI のためにクラスを使ったりアノテーションを書いたりといった追加の記述が必要になることが普通です.
インターフェースの代わりにエフェクトを使う
上記の例では「ツイートを永続化するためのデータストア」がドメイン層に存在するといった形でドメインを記述しましたが, 少し見方を変えて, ドメイン層のビジネスロジックが「ツイートを永続化する」という効果 (エフェクト) を持つという形でもドメインを記述できそうです.
というわけで具体的には以下のようになります.
ここで tweetStore.insert()
などは呼び出された時点で副作用を発生させる関数ではなく, エフェクトのコンストラクタになっています.
import type { Effectful } from "@susisu/effectful"; // エフェクトの定義は省略 function* postTweet( input: PostTweetInput, ): Effectful<"tweetStore" | "eventStream", void> { // ツイートを保存 const tweet: Tweet = { id: idGenerator.next(), text: input.text, postedAt: clock.now(), }; yield* tweetStore.insert(tweet); // イベントを送信 const event: TweetPostedEvent = { type: "tweetPosted", tweetId: tweet.id, text: tweet.text, postedAt: tweet.postedAt, }; yield* eventStream.publish(event); }
このようにエフェクトを発生させる関数として記述したビジネスロジックは, 他のビジネスロジックからは yield* postTweet({ text: "Hello" })
のように呼び出せます.
また実際にプログラムを実行するときは, 以下のように適当な関数を用意して, エフェクトを具体的な技術を使った操作に翻訳してやります.
import { run } from "@susisu/effectful"; function runDomain<T>( program: Effectful<"tweetStore" | "eventStream" | ..., T> ): Promise<T> { return run(program, x => Promise.resolve(x), { tweetStore: (eff, resume) => { switch (eff.data.type) { case "insert": { const tweet = eff.data.tweet; return db.exec( "INSERT INTO tweets (id, text, posted_at) VALUES (?, ?, ?)", [tweet.id, tweet.text, tweet.postedAt] ).then(() => resume(eff.data.ev(undefined))); } // case ... } }, eventStream: (eff, resume) => { switch (eff.data.type) { case "publish": { const event = eff.data.event; return redis.publish("events", JSON.stringify(event)) .then(() => resume(eff.data.ev(undefined))); } // case ... } }, }); } runDomain(postTweet({ text: "Hello" }));
この方法の良いところは, 従来の DI と同様の実装の注入が自然に実現できながらも, そのための追加の記述をドメイン層に必要としないところです. エフェクトはもはやドメインにとって本質的な概念で, これ自体がドメインの記述のための道具であり, 過不足がありません.
追記: 非同期処理であるかどうかもドメインの記述にとって本質的ではないはずですが, これもドメインの外に追いやれていますね.
まとめ
- ドメインモデリングにおいて, 具体的な技術を抽象化するインターフェースの代わりに, エフェクトを基本的な概念として導入することができそう
- (TypeScript で) ドメイン層に対して DI を行うことを考えると, ドメインの記述に余計な要素が少なくなるという点で, 従来のインターフェース + DI よりもエフェクトの方が優れているのではないか
ここまで全部机上の議論なので, なんらか実践したりしてアップデートがあったらまた記事を書きます.