DIP――ブラウザの機能を、ポートの向こうへ置く
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第8回。SOLID の5つ目の原則 DIP を、「コピー」のボタン1つから確かめます。コピーを頼むコードはブラウザを知らず、約束(ポート)だけを知る。実行時の呼び出しは外へ、import は内へ。import の向きを数え、DI・DI コンテナ・ファクトリの役目を読み、わざと境界を破って何が止めるかまで、図16点で読み解…
検証:わざと境界を破ってみる
ここまでで、コピーを頼むユースケースが、約束(ClipboardGateway)だけを知り、ブラウザの実装は組み立て役が選んで渡していることを見てきました。DIPにかなった形です。けれど、形がそうなっていることと、崩れたときに誰かが気づくことは、別の話です。このページでは、わざと境界を破り、何が止めてくれるのかを実測します。
検証は、連載2.2と同じ手順で進めました。プラグインのリポジトリを git worktree で一時作業用のコピーに写し、公開しているプラグインには何も入れていません。まず何も変えずにテストを流すと、Node のテストが63件、すべて通りました。型検査も、内側だけの検査と全体の検査の両方が通りました。そのうえで、ユースケースの1か所だけを書き換えて、もう一度流します。
実験1:ユースケースが navigator を直接呼ぶ
1つ目の実験は、いちばんありがちな崩れ方です。「わざわざ約束を通さなくても、ここで直接ブラウザを呼べば早い」と、ユースケースの1行を書き換えます。
--- a/web/src/application/HandleShareClickUseCase.ts
+++ b/web/src/application/HandleShareClickUseCase.ts
@@ -41,7 +41,7 @@ export class HandleShareClickUseCase {
case 'copy': {
preventDefault();
try {
- await this.#gateways.clipboard.write(button.url);
+ await navigator.clipboard.writeText(button.url);
return 'copied';
} catch {
this.#gateways.clipboard.fallback?.(button.url);変えたのは1行だけです。約束の write を通していたところを、ブラウザの navigator.clipboard.writeText に直接つなぎました。ブラウザで動かせば、コピーは今までどおり動きます。この状態で、型検査とテストを流しました。
$ npx tsc -p tsconfig.core.json
web/src/application/HandleShareClickUseCase.ts(44,17): error TS2304: Cannot find name 'navigator'.
$ npx tsc -p tsconfig.json
$ node --test 'web/test/**/*.test.ts'
✖ copy はクリップボードへ書いて copied を返し、既定動作を止める
✖ 書き込みの完了を待ってから結果を返す(完了前に成功を名乗らない)
✖ 内側の型検査はDOMもNodeの型もなく成功し、Elementを混ぜると失敗する
✖ クリックの通知は要素ごと・イベント名ごとに分かれる
ℹ tests 63
ℹ pass 59
ℹ fail 4上から読みます。1つ目の型検査は、domain と application だけを、ブラウザの型を持たない設定(tsconfig.core.json)で調べるものです。44行目の navigator について「Cannot find name 'navigator'(navigator という名前が見つからない)」と止めました。内側の層には、そもそもブラウザという言葉が存在しない設定になっているからです。2つ目の、ブラウザの型を含む全体の型検査は、何も出力せずに通りました。全体の型検査だけを見ていたら、この崩れには気づけませんでした。
テストは63件のうち4件が落ちました。「copy はクリップボードへ書いて copied を返す」テストは、代役を渡しているのに、ユースケースが代役を無視して本物のブラウザを呼ぶので、代役には何も記録されません。しかもテストを動かす Node では、navigator はあるのに navigator.clipboard がありません(手元の Node 24 で確かめると undefined でした)。そのため書き込みはいつも失敗し、copied ではなく copy-fallback が返りました。
ここで1つ、見落としやすいことがあります。「クリップボードが失敗したら fallback を出し、copied とは返さない」というテストは、この実験でも通りました。Node にはクリップボードが無いので、書き込みはいつも失敗し、たまたま期待どおりの結果になったのです。裏返せば、navigator を直接呼ぶ形では、テストで「コピーに成功した場合」を作ることができません。成功も失敗も自由に作れるのは、約束を通して代役を渡せる形だけです。これが、DIP がテストを書きやすくする、ということの具体的な中身です。
実験2:ユースケースがブラウザの実装を import する
2つ目の実験は、もう少し行儀のよさそうな崩れ方です。navigator を直接書く代わりに、ブラウザの実装(BrowserClipboard)を import して、ユースケースの中で new します。いわば、2ページ目の「ボタンが保存先を自分で作る形」を、実物でやってみるわけです。
--- a/web/src/application/HandleShareClickUseCase.ts
+++ b/web/src/application/HandleShareClickUseCase.ts
@@ -3,6 +3,7 @@
* domain perform side effects. The outcome is returned, and presentation decides how to show it.
*/
import type { ShareGateways } from '../domain/gateway/ShareGateways.ts';
+import { BrowserClipboard } from '../infrastructure/BrowserClipboard.ts';
import { ShareActionPolicy } from '../domain/service/ShareActionPolicy.ts';
import type { ShareButtonViewModel } from './BuildShareBarUseCase.ts';
@@ -41,7 +42,7 @@ export class HandleShareClickUseCase {
case 'copy': {
preventDefault();
try {
- await this.#gateways.clipboard.write(button.url);
+ await new BrowserClipboard().write(button.url);
return 'copied';結果は、依存の境界のテストが、崩した場所を名指しで止めました。「型import・再exportを含めて許可した方向だけに依存する」というテストが落ち、その理由として application/HandleShareClickUseCase.ts から application → ../infrastructure/BrowserClipboard.ts へ向かう依存が見つかった、と表示されます。テスト全体では63件のうち5件が落ちました。内側の型検査も、import した BrowserClipboard.ts の中にある navigator と window の3か所で止まりました。
この依存の境界のテスト(architecture.test.ts)は、フォルダの名前だけでなく、ファイルの中の import を1つずつ読み取って、許可した向きだけかを確かめています。許可している向きは、domain は domain だけ、application は application と domain、infrastructure は infrastructure と domain、presentation は presentation と application、そして composition は全部です。さらに、禁止した形の書き方(逆向きの import、動的な読み込みによる迂回、表示の層での navigator の直接呼び出しなど)をわざと書いた例を17通り用意し、そのすべてを検査が見つけることまで確かめています。見張りそのものが効いているかも、テストで見張っているのです。
3つの見張りと、見張れないもの
2つの実験で働いた見張りを、まとめておきます。1つ目は、ブラウザの型を持たない内側の型検査です。方針のコードにブラウザの名前が1つでも入れば、動かす前に止まります。2つ目は、依存の境界のテストです。禁止した向きの import を、ファイル名と行き先を名指しして止めます。3つ目は、代役を渡すテストです。約束を通していないコードは、代役を渡しても思いどおりに動かないので、期待した結果と食い違って落ちます。
境界は、書いたときに守られていても、次の変更で崩れます。守り続けるのは見張りの仕事です。どの見張りも、1つだけでは足りませんでした。全体の型検査は実験1を見逃しましたし、grep はコメントとコードを区別できませんでした。
一方で、見張れないものもあります。3つの見張りが確かめているのは、どの名前を知っているかと、約束どおりに動くかです。約束の中身そのものがよいか、たとえば write が成功と失敗を正直に返しているか、ブラウザの実装が権限を断られたときに本当に失敗を返すか、までは保証しません。BrowserClipboard の中身は、ブラウザで動かして確かめる必要があります。約束の形をそろえても、本番の実装だけが黙って失敗するなら、差し替えられるとは言えないからです。
考えてみる:見張りのテストがあるなら、境界を破るコードは、そもそも書けないのでは?
書くことはできます。見張りは、書いたあとで止めるものです。実験でも、書き換えたコードはブラウザでは動きましたし、全体の型検査も通りました。見張りの価値は、崩れたことに、レビューの目ではなく機械が、しかも崩れた直後に気づくことにあります。人の目では「1行だけだから」と見過ごしやすい崩れほど、見張りが効きます。







このページ(5ページ目)の感想・質問・設計へのコメント