前回、個人開発でクリーンアーキテクチャを採用するかどうかの判断基準を書いた。今回はその続きで、「採用すると決めた後、実際にどうports/adaptersを切るか」を書く。結論から言うと、私が実務でやっているのは「外部システムへの操作を1つの動詞にまとめてinterfaceにする」というシンプルな作業だ。domain/ports/adapters/usecaseという4層構成そのものは、このブログのWordPress下書き同期ツール(TypeScript製)で実際に採用しているので、そのコードを題材に書く。
ports/adaptersが指すもの
まず言葉の整理から。クリーンアーキテクチャの文脈で言うports/adaptersは、以下のような役割分担を指す。
- port: 「外部システムに対して何をしたいか」を定義するinterface。実装は書かない
- adapter: portを実装する具体的なコード。REST APIを叩く、ファイルを読む、といった実装詳細はここに閉じ込める
私のWordPress同期ツールでは、WordPressClientというportを1つ定義している。
export interface WordPressClient {
upsertPost(input: UpsertPostInput): Promise<WordPressPost>;
}
これを実装するWordPressRestClientというadapterが、fetchでWordPress REST APIを叩く処理を持つ。usecase層やdomain層はWordPressClientというinterfaceの存在だけを知っていて、その裏でfetchが呼ばれているのか、それとも別の手段で通信しているのかを一切知らない。
切り方で最初に迷うところ
ports/adaptersを初めて書くとき、一番迷うのは「粒度をどこに合わせるか」だと思う。実際に私も最初、WordPress操作を「投稿を作る」「投稿を更新する」という2つのメソッドに分けて書いていた。
これを1つのupsertPostにまとめ直したのには理由がある。ユースケース側(記事ファイルをWordPressに同期する処理)から見たとき、「新規か更新かはWordPress側の都合であって、呼び出し元が気にする話ではない」と気づいたからだ。同期したい記事にwp_post_idがあれば更新、なければ新規作成——この判断はadapter内に隠蔽し、usecase層は「同期したい」という1つの意図だけを渡せばいいようにした。
export interface UpsertPostInput {
title: string;
htmlBody: string;
slug: string;
categorySlug: string;
existingPostId?: number; // 未指定なら新規作成
}
ここで学んだのは、portの粒度は「実装の都合」ではなく「呼び出し元が本当に持ちたい意図」で決める、ということだ。「作成メソッドと更新メソッドを別々に用意する」のは実装者目線の分け方であって、使う側目線では「同期したい」という1つの動詞で足りていた。
domain層を「何も知らない」状態に保つ
ports/adaptersと並んで重要なのが、domain層を外部依存から完全に切り離すことだ。私の同期ツールでは、frontmatter(記事のメタ情報)のパースとシリアライズをdomain層に置いている。ここにはHTTPクライアントもファイルシステムも一切登場しない。文字列を受け取り、構造化されたオブジェクトを返すだけの純粋関数だ。
これができていると、テストがモック地獄にならない。domain層のテストは「この文字列を渡したら、このオブジェクトが返る」という入出力だけで完結する。WordPress APIをモックする必要も、ファイルシステムをモックする必要もない。逆に言うと、domain層のテストを書こうとしてモックが必要になった時点で、それはdomain層に外部依存が漏れ出しているサインだと判断していい。
usecase層は「配線」だけをする
usecase層(私のツールではsyncDraftという1関数)の役割は、portsを組み合わせてアプリケーション固有のフローを表現することだけだ。中身を見ると分かりやすい。
export async function syncDraft(filePath: string, deps: SyncDraftDependencies): Promise<SyncOutcome> {
const text = deps.fs.readFile(filePath);
const doc = parseDocument(text); // domain層
const htmlBody = deps.renderer.render(doc.body); // port経由
const post = await deps.wordpress.upsertPost({ ...doc.frontmatter切り出し }); // port経由
// ...
}
depsにfs・renderer・wordpressの3つのportをまとめて渡し、この関数自体は「どのportがどう実装されているか」を一切知らない。実装(fetchを呼ぶか、pandocコマンドを叩くか)を知っているのは、エントリーポイントであるwp-sync.tsだけに限定した。依存を組み立てて注入するのはエントリーポイントの仕事、それを使って処理を組み立てるのはusecase層の仕事、と役割を分けている。
この構成にしてから実際に助かったのは、WordPress側の下書き作成をREST APIからGraphQLに切り替えるような変更が発生しても——実際にはまだ起きていないが——影響範囲がadapter1ファイルに閉じることが設計上保証されている点だ。usecase層とdomain層のコードは1行も触らずに済む。
ports/adaptersを導入するとき避けたい失敗
自分がやりかけて途中で気づいた失敗を1つ書いておく。最初、ファイルシステム操作(readFile/writeFile)をportにせず、usecase層から直接Node.jsのfsモジュールを呼んでいた。「ファイル読み書きぐらい薄い処理だから、わざわざportにしなくていい」と考えたためだ。
だが後からユニットテストを書こうとしたとき、この判断のツケが回ってきた。usecase層のテストのために、実ファイルシステムへの読み書きを避けられなくなっていたのだ。結局FileSystemPortというinterfaceを追加で切り出し、テストではreadFile/writeFileをインメモリの実装に差し替えられるようにした。「外部への入出力かどうか」で機械的に線を引く方が、後から後悔しないという教訓になった。
判断のチェックリスト
新しくportを切るかどうか迷ったとき、自分は次の質問で判断している。
まとめ
- portは「外部システムに何をしたいか」のinterface、adapterはその実装。呼び出し元が持ちたい意図の単位でportの粒度を決める
- domain層は外部依存ゼロに保つと、テストがモック地獄にならない
- usecase層はportsを組み合わせる「配線」に徹し、実装の詳細を知っているのはエントリーポイントだけにする
- 「薄い処理だからport化しなくていい」という判断は、テストを書く段階で後悔することがある。外部I/Oかどうかで機械的に線を引く方が安全
次回は、複業4事業それぞれで使っている技術スタックの選定基準について書く予定だ。
関連記事
この実践記録はX(@amazenpapa)でも発信しています。