
このプロダクトは開発開始から約2年が経ちました。バックエンドは長いあいだ presentation / domain / repository の3層で書いてきましたが、最近これにユースケース層を加えた4層へと再構成しました。 この記事では、なぜ最初から4層にしなかったのか、そしてなぜ今になって構成を取り直したのか、を書きます。
シンプルに始めた
当初のバックエンドは presentation / domain / repository の3層でした。守ろうとしていたのは「依存方向を内側に保つ」という一点です。層がいくつあるかは本質ではなく、依存の矢印がビジネスルールを置く domain 側を向き続けていれば構成として成立する。そう割り切っていました。
当時のドキュメントには、ユースケース層について「必要になるまで実装しない」と書いていました。実際に一度入れてみたこともあります。ですがそこで起きていたのは入出力の薄い変換だけ。処理の分岐もほとんどなく、repository を呼び出すだけの層でした。機能数が少なく扱う情報量も限られていて、presentation と分けるほどの責務がユースケース側に残らなかった。テストも書いてはみたものの存在意義が薄く、結局この層は外しました。ユースケース層を持たないことは、その時点での戦略的な判断でした。
少人数で開発を回すには、先に器を整えて維持コストを上げるより、必要な分だけ書くほうが現実的でした。SOLID、KISS、YAGNI といった原則を日々の意思決定の物差しにして、「いま要らないものは作らない」を素直に実行していました。
コードベースが育って、痛みになった
しかし、その割り切りには期限がありました。使われ続けるソフトウェアは変化を強いられます。機能が増え、扱う概念が増え、コードベースは絶えず複雑さを積み増していきます。怠けたから起きるのではなく、生きているソフトウェアだからこそ避けられない現象です。 私たちのプロダクトでも、機能領域が広がるにつれて、1つのファイル・1つのハンドラに乗る情報量が増えていきました。やがて3層のシンプルさは、そのままでは立ち行かなくなります。症状は次の4つでした。
- 単一ファイルの肥大化。 最大のものは10を超えるハンドラを抱え、3000行近くまで膨らんでいました。1つのファイルに複数の責務が同居し、読み解くだけでコストがかかります。
- 責務の混在。 入出力の変換、複数の集約をまたぐビジネスロジック、関連エンティティの取得、トランザクション制御などが、同じ場所に並んでいました。
- トランザクション境界の散らばり。
BeginTx/Commit/Rollbackの3点セットが、書き込み系の80近いハンドラそれぞれに直接書かれていました。同じ定型がそれだけ重複し、書き方を統一する場所もありません。 - テストの書きにくさ。 責務が同居して密結合だったうえ、repository も具象のまま直接依存しており、一部だけを切り離してモックを挟む余地がありませんでした。結果として domain 層以外の単体テストは書きにくく、振る舞いの担保はE2Eのシナリオテストに寄せていました。
どれも、機能が増える過程で少しずつ膨らんできたものです。当時の割り切りは、もう実態に合わなくなっていました。
痛みの出た責務を切り出す
やること自体は一つです。痛みの出ている責務を取り出して、別の場所に置き直す。ただし結果としてコードベース全体に手を入れることになるので、いきなり本体には入れず、段取りを踏みました。
進め方
責務を切り出す作業は、コードベースの広い範囲に手を入れます。動いているものを大きく動かす以上、リスクは当然あります。そこで本体の作業に入る前に、まずE2Eのシナリオテストを刷新しました。大胆に動かしても壊れたらすぐ気づける状態を、先に用意したわけです(このシナリオテストの作り込みについては別の記事で触れています)。
その状態を土台に、設計の判断は人間が担い、機械的な変換・パターンの適用・繰り返しの多い作業はコーディングエージェントに任せました。一気に変えるのではなく課題ごとに議論を分け、集約ごとに小さくPRを刻んで、段階的に合意を重ねました。
その上で、切り出しは依存順に進めました。トランザクション境界 → インフラ層 → ユースケース層 の順に、後段が依存する受け皿を先に作り、最後に、それらを使う側として presentation を分解していきます。
- トランザクション境界。 まず「トランザクションをどう管理するか」のメカニズムを決めました。domain にインターフェースを置き、その実装をインフラ側に用意します。
- インフラ層。 次に、インフラを「相手にしているもの」の軸で整理しました。このとき、それまで具象のまま呼んでいた repository も、トランザクション境界と同じく domain にインターフェースを置いてインフラ側で実装する形に揃えています。
- ユースケース層。 そして最後に、肥大化した presentation そのものを分解しました。受け皿となるトランザクション境界とインフラのインターフェースは先に揃っています。あとは presentation に同居していた責務——アプリケーションのフローや、書き込み系に散らばっていたトランザクション境界——を、そこから抜き出していくだけです。抜き出した責務の引き受け先として立ち上がったのが、ユースケース層でした。最初に一度入れて外した層が、今度は presentation から分解された責務を伴って戻ってきた、ということです。
整理した結果
痛みの出ていた4つの症状は、それぞれ次のように収まりました。
- 肥大化したハンドラは、ビジネスロジックが抜けたぶん薄くなりました。最大で3000行近くあったファイルも、責務が移った分だけ小さくなっています。
- 責務の混在は、ビジネスルールが domain へ、関連エンティティの解決や権限チェックがユースケース層へと分かれ、presentation には入出力の変換という本来の責務だけが残りました。
- 書き込み系のハンドラに散らばっていたトランザクション境界は、ユースケース層の1か所に集約されました。
- インターフェース化した repository を抽象越しに差し替えられるようになり、テストも domain 以外の層に書けるようになっています。
痛みの出ていた責務が、それぞれの居場所に収まった状態です。 構成としては presentation / usecase / domain / infrastructure の4層に整理し、加えてこれらを束ねる組み立て層(エントリポイント・DI・ルーティング)を分けています。それぞれの責務を一言ずつ挙げます。
- presentation:プロトコルの境界。入出力の変換だけを担い、ビジネス上の判断は持ちません。
- usecase:アプリケーションのフロー。トランザクション境界、関連エンティティの解決、権限チェックがここに集まります。
- domain:ビジネスルールとドメインモデル、そしてインフラ層が実装するインターフェース。依存性逆転の起点です。
- infrastructure:domain が定義したインターフェースを実装する層。依存先の種類で分け、DBは repository、外部APIやクラウド・認証は gateway のように分類しています。
- 組み立て層:エントリポイント、DI、ルーティング。
層がひとつ増えただけに見えますが、先に層という器を用意したわけではありません。痛みの出ていた責務を順に切り出していった結果です。
判断はフェーズで変わる
最初にシンプルに始めた判断も、いま責務を切り出した判断も、その時々のフェーズに対する答えという点では同じです。むしろ、先回りで抽象化を仕込まず何も作り込まなかったからこそ、後から責務をいかようにも切り出せる選択肢の広さが残っていました。薄いレイヤーを抱え続けるより、痛んでから切り出すほうが筋が良かった。そう考えています。
今の4層も、おそらく終着点ではありません。別のフェーズが来たとき、あるいは別の道具が手に入ったとき、また取り直されるのだと思います。実のところ、今回もタイミングを狙って当てたわけではなく、痛みが溜まったところにリファクタリングの時間ができて、ようやく切り出せた、というのが正直なところです。それでも動けたのは、先に器を作り込まずシンプルに保っていたからでした。次のフェーズでも、そうありたいと思っています。
フェーズごとに変わったのは、3層か4層かという構成の判断でした。変わらなかったのは、その判断を貫く原則のほうです。必要になるまで作らないことと、必要になったら切り出すこと。一見逆を向くこの2つは、別々の判断ではなく、YAGNIという同じ原則の両面なのだと思います。