PROMARI JOURNAL

DIP――ブラウザの機能を、ポートの向こうへ置く

作成: / 公開: / 内容更新:

執筆:tamito0201 / 掲載・運営:プロマリ

丸い共有ボタンの裏側を掘る連載の第8回。SOLID の5つ目の原則 DIP を、「コピー」のボタン1つから確かめます。コピーを頼むコードはブラウザを知らず、約束(ポート)だけを知る。実行時の呼び出しは外へ、import は内へ。import の向きを数え、DI・DI コンテナ・ファクトリの役目を読み、わざと境界を破って何が止めるかまで、図16点で読み解…

PASS IT ON

ひとつの発見を、次の会話へ。

検証:わざと境界を破ってみる

ここまでで、コピーを頼むユースケースが、約束(ClipboardGateway)だけを知り、ブラウザの実装は組み立て役が選んで渡していることを見てきました。DIPにかなった形です。けれど、形がそうなっていることと、崩れたときに誰かが気づくことは、別の話です。このページでは、わざと境界を破り、何が止めてくれるのかを実測します。

検証は、連載2.2と同じ手順で進めました。プラグインのリポジトリを git worktree で一時作業用のコピーに写し、公開しているプラグインには何も入れていません。まず何も変えずにテストを流すと、Node のテストが63件、すべて通りました。型検査も、内側だけの検査と全体の検査の両方が通りました。そのうえで、ユースケースの1か所だけを書き換えて、もう一度流します。

実験1:ユースケースが navigator を直接呼ぶ

1つ目の実験は、いちばんありがちな崩れ方です。「わざわざ約束を通さなくても、ここで直接ブラウザを呼べば早い」と、ユースケースの1行を書き換えます。

Diff実験1の git diff。- が元の行、+ が書き換えた行
--- 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 に直接つなぎました。ブラウザで動かせば、コピーは今までどおり動きます。この状態で、型検査とテストを流しました。

Shell実験1のあとに型検査とテストを流した結果(抜き出し・時間の表示は省略)
$ 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 が返りました。

図5.1-1 application が navigator を直接呼んだら
図5.1-1 application が navigator を直接呼んだら。実測(わざと破った実験)。DOM の型を持たない内側の型検査と、代役を渡すテストが気づきました。全体の型検査だけなら、黙って通っていました。図5.1-1 application が navigator を直接呼んだら。実測(わざと破った実験)。DOM の型を持たない内側の型検査と、代役を渡すテストが気づきました。全体の型検査だけなら、黙って通っていました。

ここで1つ、見落としやすいことがあります。「クリップボードが失敗したら fallback を出し、copied とは返さない」というテストは、この実験でも通りました。Node にはクリップボードが無いので、書き込みはいつも失敗し、たまたま期待どおりの結果になったのです。裏返せば、navigator を直接呼ぶ形では、テストで「コピーに成功した場合」を作ることができません。成功も失敗も自由に作れるのは、約束を通して代役を渡せる形だけです。これが、DIP がテストを書きやすくする、ということの具体的な中身です。

実験2:ユースケースがブラウザの実装を import する

2つ目の実験は、もう少し行儀のよさそうな崩れ方です。navigator を直接書く代わりに、ブラウザの実装(BrowserClipboard)を import して、ユースケースの中で new します。いわば、2ページ目の「ボタンが保存先を自分で作る形」を、実物でやってみるわけです。

Diff実験2の git diff。+ が足した行、- が消した行
--- 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か所で止まりました。

図5.2-1 application がブラウザの実装を import したら
図5.2-1 application がブラウザの実装を import したら。実測(わざと破った実験)。禁止した向きの import は、依存の境界のテストがファイル名と行き先を名指しして止めました。図5.2-1 application がブラウザの実装を import したら。実測(わざと破った実験)。禁止した向きの import は、依存の境界のテストがファイル名と行き先を名指しして止めました。

この依存の境界のテスト(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行だけだから」と見過ごしやすい崩れほど、見張りが効きます。

COMMENTS
コメント…

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

PASS IT ON

この気づきを、誰かにも。

この記事を書いた人

Takaomi Murasaki

Promari SNS Shareの開発を通して、Webの仕組みや設計の選び方を紹介しています。動く実装と、その判断に至るまでを記事に残しています。

公開記事 10 件

プロフィールを見る
THANK YOU FOR READING.すべての記事へ ↗

FROM INSIGHT TO IMPACT

「できたらいいな」を、
動く仕組みに。

記事で見つけたヒントを、あなたの事業へ。
新しいサービスも、手間のかかる業務も。
いまの課題から、つくるべきものを一緒に考えます。

開発・AI・研修の実績を見る

まだ、仕様書はいりません。

「何から始める?」から、ご一緒に。

課題がまとまっていなくても大丈夫。テーマを選ぶと相談文をご用意します。
連絡先の必須入力は、お名前とメールアドレスだけ。

まずは課題の整理から相談する

ご相談後の流れ

  1. 01 内容を確認
  2. 02 メールでご連絡
  3. 03 課題・進め方をご相談
送信だけで契約やお申込みが確定することはありません。