📚

依存が浮かび上がる!?Effect.tsで依存グラフをレイヤで設計しよう、そしてDIコンテナについて考える

に公開

この記事のコード例は、bunを利用して実際に実行できます。

依存が浮かび上がる

まずは依存がない基本から

売上伝票の一覧から、売上の合計を計算することを考えてみます。
とりあえず、単純に合計値を計算することを考えます。

import { Effect, Stream } from "effect";

//     ┌─ Effect.Effect<number, never, never>
//     ▼
const profitSum = Effect.gen(function* () {
  const profits = Stream.make(1, 2, 3);
  return yield* profits.pipe(Stream.runSum);
});

const v = await Effect.runPromise(profitSum);
console.log(v); // 6

このとき、 profitSum の型は Effect.Effect<number, never, never> になります。

ここで、この三つのタイプパラメータの意味を軽く書いておきます。

         ┌─── このエフェクトが作りたい値の型
         │       ┌─── このエフェクトが失敗したときのエラーの型 (ありえるものすべて)
         │       │      ┌─── このエフェクトの依存
         ▼       ▼      ▼
Effect<number, Error, never>

ここで、三つめのパラメータが今回主題の依存になりますね。
なお、初めての場合は、エフェクトというのはプログラムのこと、または単に(asyncかもしれない)実行を待つ関数のこと、というように理解しておいて良いかと思います。

never というのは、ゼロ個の型をORしたもの、もしくは空の列 [] のようなものだと思えばよいです。
never, A1, A1 | A2, A1 | A2 | A3, A1 | A2 | A3 | A4 .... のような列挙を想像して、これが自然なんだな、と感じてよいです。感じてください。

依存する

さて、実際は伝票の情報はどこからか取ってこなければならないので、そのようなものが必要であることを書きます。

import { Context, Effect, Stream } from "effect";

class Repository extends Context.Tag("Repository")<
  Repository,
  {
    readonly fetchReceipts: () => Effect.Effect<Stream.Stream<number>>;
  }
>() {}

//     ┌─ Effect.Effect<number, never, Repository>
//     ▼
const profitSum = Effect.gen(function* () {
  const repository = yield* Repository;
  const profits = yield* repository.fetchReceipts();
  return yield* profits.pipe(Stream.runSum);
});

const v = await Effect.runPromise(profitSum); // タイプエラー : 'Effect<number, unknown, Repository>' は 'Effect<number, unknown, never>' に代入できません
console.log(v);

ここでは、どのようなものを必要とするか、という観点においてRepositoryという名前を付けたコンテキストとして定義しています。
なにを実現できるものが必要か、そしてそれはどのような役割を持つものか、というのを合わせて定義、要求します。(今回はfetchReceiptsという名前から役割が自明だとしておきます)
ここで起きた変化を見てみましょう。

  • エフェクトのタイプが Effect.Effect<number, never, Repository> に変化した。特に、最後の依存に Repository が付与された。
  • Effect.runPromise の箇所がエラーになった。 このままでは実行することができない状態。

Effect.runPromise はエフェクトの世界を、JavaScriptのネイティブなPromiseの世界に持ってくる機構です。このとき、依存を実現するものが足りていないと、このようなエラーになります。

依存を実装する

要求される依存を実装して与えてみましょう。

import { Context, Effect, Layer, Stream } from "effect";

class Repository extends Context.Tag("Repository")<
  Repository,
  {
    readonly fetchReceipts: () => Effect.Effect<Stream.Stream<number>>;
  }
>() {}

const profitSum = Effect.gen(function* () {
  const repository = yield* Repository;
  const profits = yield* repository.fetchReceipts();
  return yield* profits.pipe(Stream.runSum);
});

// ====

const FakeRepositoryLive = Layer.succeed(Repository)({
  fetchReceipts: () => Effect.succeed(Stream.make(1, 2, 3)),
});

const v = await Effect.runPromise(
  profitSum.pipe(Effect.provide(FakeRepositoryLive)),
);
console.log(v); // 6

すべての依存を実装したので、実行することができました。ポイントを見てみます。

  • 依存の実現は Layer で行われます。レイヤというのは、まさにソフトウェアアーキテクチャにおけるレイヤ(層)そのものです。レイヤを作る、設計するための道具です。
  • 依存の注入は Effect.provide(注入するもの)(対象のエフェクト) で行われます。
    • profitSum.pipe(Effect.provide(FakeRepositoryLive)) のタイプは Effect<number, never, never> になります。依存するものがこれ以上なくなったエフェクトです。

まとめ

この例は、あまり現実に即したものではないかもしれませんが、明示的にどこかにコンテキストを使っているんだ、と書かなくても、自動的に 依存が使うだけで型に浮かび上がってくる 様子が伝われば幸いです。

ちなみに、これはTypeScriptのジェネレータ関数の型推論の力で実現されています。なぜこのような推論が行えるのか、という技術的な背景については、まさにmizchiさんのフロントエンドの main() を合成関数として副作用を集約する#副作用を集約したい。 全ては Generator だったという記事の項で書かれていることですね。

より現実的な例は、ぜひ公式のチュートリアルManaging Layersを参照するのがよいかと思います。

また、ここで説明しきれていない部分も、公式ドキュメントを上から読むと専門的な知識がなくても理解できるようになっています。ぜひトライしてみてください。

ソフトウェア工学の視点において

以下の話は私の解釈によるものです。経験のなかで、なぜ依存の逆転に注目するのか、そしてDIコンテナがその先として必要になるのか、考察したものです。

注意点として、私は趣味も実務も通してほとんどDIコンテナの利用経験がありません。必要としなかった、というのと、それが必要である理由に納得しきることが、これまでできていなかったというところかと思います。今回、Effect.tsを利用したい、という気持ちに先立ち、段々と分かってきたような気がするので、まとめてみました。

依存性逆転の原則

まずは一般的な話から入りましょう。ソフトウェアを構築します。それも、日々変化を続け、規模が大きなものです。
ソフトウェアとは基本的には逐次の実行で、それをファイルや関数に分割し、組み上げていくことで規模の大きなソフトウェアを、小さな単位で捉えようとします。
しかし、単なる愚直な分割だけでは、単に見かけのコードサイズが小さくなるだけです。必要なのは、責務範囲を明確にしてスコープを小さくすることです。

どうするか? ソフトウェアの相互の繋りを、どのようなことを満たせばよいか、という "約束事" で分離します。それがインターフェースだったりトレイトだったりします。

これは、依存性逆転の原則(Dependency Inversion Principle; DIP)だとか、抽象への依存、として語られます。

関数型プログラミング言語とDIP

DIPにおいて関数型プログラミング言語を絡めて考えることは重要です。なぜでしょうか。

DIPにより、単に詳細な実装を呼びだす関係から、抽象を実現したものを渡す、という構図に変化します。
抽象を実現したもの、というのは原理的には関数を渡す、ということです。そのためには、関数が第一級であることは重要な観点です。[1]

DIコンテナ(フレームワーク)がなぜ必要なのか

原理的には、DIをしたいだけであれば、単に引数として関数(またはインターフェースを実装したクラスインスタンスなどでも)を渡せば実現できることを、なぜDIコンテナというものを介してやるのでしょうか。

これは、引数で渡していくこと vs 大域的に渡すこと、という構図で整理して見るのがよいでしょう。
たとえば、Reactでも単にpropsを利用するのか、useContextを利用するのか、といった話として見れます。

どちらを利用したとしてもDIPは実現されます。ですから、そこから先は、さらになぜコンテキストのようなものを介して実現するのか、という話になります。

まず、それらが何を実現するでしょうか。

  1. 大域的な依存の注入
    • 引数で渡して回ることなく、依存を渡せる
    • 下位レイヤで依存したものに対して、自動で依存を引き継ぐ
  2. 依存注入のタイミングの制御
    • 依存の実体を入れる場所はコントロール可能です。より具体的には、「このファイルでのみ注入する」といったことができます
  3. 同一な依存の一斉注入
    • 実体を用意する、そしてそれがシングルトンであることを、依存注入タイミングをコントロールすることで保証できます

この三つの特徴を、 グローバル, エントリーポイント, シングルトンとそれぞれ読んで特徴付けられないかと考えています。

そして、これらは良く、単にそうするとなんとなく楽だから、引数で毎回渡さなくてすむから、といった観点で語られることがありますが、私はそれだけではメリットとして弱いと考えています。[2]
また、ReactのuseContextは、今回のEffect.tsの例とは異なり、依存していることが型に表れなくなります。これもメリットのように語られることがありますが、これにも違和感があり、理想的にはEffect.tsが成したような、依存を知る仕組みがあったほうが良いように思われます。

グローバル: 大域的な依存の注入

これは上にも述べた通り、楽、という側面で上げられる特性です。
もちろん、それも(今回私は最重要としては取り上げなかったものの)重要なメリットです。
また、依存していること自体は裏で自動で引き継がれる、という側面も大事な側面かと思います。

エントリーポイント: 依存注入のタイミングの制御

エントリーポイントを明確に分離できる、などと言うほうがより詳細になるかと思います。
関数型は純粋関数(=参照透過性をもつ関数)の組み合わせで実現したいというモチベーションがあります。
しかし、アプリケーションそのものは純粋にはなりきれません。そうした、外界との接点が "エントリーポイント" です。

抽象に具体を与えるポイントを限定することは、アプリケーションの役割を適切にミニマルに分離し、テストしやすいアプリケーション構成を実現します。
逆に言えば、テストできない場所を最小化します。 [3]

シングルトン: 同一な依存の一斉注入

これは上記二つの観点から導かれることですが、ランタイムとして組み立てられたときに、依存したものが同一のものであるべき、ということも生じるでしょう。

引数として何度も渡していく方式では、すべての関数が、どのような実装を渡すのか、ということに責務を持つことになってしまいます。
極端な例では、途中の関数を実装する人が、なにも分からず setupMockedRepository() のようなリポジトリのテスト用モックを呼び出してしまったとしても、チェックは通ってしまいます。
よく、「置き換え可能にする」という言いかたをされますが、置き換えをするのは、まるごとランタイムが異なる軸で行う、プログラムの大部分から見れば置き換える必要のないものでもあります。(置き換えをしたい、という主体はだいぶ高次元にいるようなイメージです)

上から受けとり、下にそのまま渡しなさい、というルールを自動で実現する、という意味において、たとえば特別なシンボルを付加するといったことも考えられますが、それを仕組みで解決しようというのがシングルトンの観点です。

書いたあとで

Gemini 2.5 ProにDIコンテナについて引数で良くない?なぜ?と聞いて説明させたら、わりと上で書いた観点が述べられて、おーとなりました(自分とGeminiに対して)。

ただ、私自信はあまり真面目にたくさんのアーキテクチャ本を読んだ、とかではないので誤りや指摘、質問や議論したい点があれば気軽に書いていただければ幸いです。

宣伝: 一緒にEffect.tsをClaude Codeに書かせませんか

有効期限: 2025年07月31日 (適宜更新します)

Effect.tsが部分的にですがプロダクションに出ました。かなり悩ましく困った問題を解決するために、中核的な役割を担ってくれました。
また、ここ数日はzodからEffect Schemaへの変更を検討しており、まず一部分から、Claude Codeと一緒にやっていっています。最高の体験です。
Claude Codeに難しいことを挑戦してもらう、または、誰でも間違うことなく書ける環境をEffect.tsで作る挑戦とも言えます。うまく整備すればAIにとっても、良い環境になると感じています。
Claude Codeがいてくれるおかげで、新機能を着実に実装しつつ、新しいことへの変革も同時に行えており、わりと楽しいです。
現在は制度が整備中ですが、AIに関した予算をコネコネしてClaude Code等のツールを使えるよう整備しています。私のClaude Code Maxも会社が出してくれてます。ヤッター

私のチームを含め、いくつかのチームが採用をしています。採用ページ → https://recruit.optimind.tech/

あと、閲覧注意ですが、本当に僕が個人的に、今の会社についてと、コワーカー募集というnote記事を書きました。

脚注
  1. 関数が第一級ではない言語でもDIPはできるのではないか、というのはそうですが、それらも原理的には間接的に、または目的に合わせて限定的に実現していると見ることができるでしょう。 ↩︎

  2. というより、それらだけのメリットの説明では納得して導入しようとまで思えなかったかなと思います。 ↩︎

  3. (広義に)テストはできるのではないか、という意見もあるかと思うので、自動テスト、高速なテストができない場所、と読み替えてもよいです。 ↩︎

GitHubで編集を提案
OPTIMINDテックブログ

Discussion