仕組みから学ぶ Web
応用読了目安 15#レイヤード#依存関係#クリーンアーキテクチャ#設計

レイヤードアーキテクチャ — 依存の向きを一方向に揃える

層に分けること自体に価値はない。価値があるのは、依存が一方向であることと、業務ロジックが技術から独立すること。

この記事の進み方

What — 層と、その間を流れるもの

Figureレイヤードアーキテクチャの各層
プレゼンテーション層+ ViewModel / Response入出力の形式を扱うController / Handler
アプリケーション層+ Command / DTOユースケースの手順を並べるUseCase / ApplicationService
ドメイン層+ Entity / ValueObject業務ルールを表現するOrder / Money / OrderPolicy
インフラ層+ Record / Row外部との通信を実装するRepository 実装 / APIClient
層をまたぐときのデータ
ViewModel / Responseデータ本体
1/4
プレゼンテーション層HTTP リクエストを受け、レスポンスを返す。JSON の形、HTTP ステータス、画面の表示形式はここの責務。業務ロジックは持たない。

上から下への一方向依存。そして層をまたぐたびに、データはその層の言葉に変換される。変換を省くと冒頭の事故になる。

依存の向きと、データの向き

ここが最も混乱するところです。依存の向きとデータの流れは別物です。

Compare素朴なレイヤードと、依存性逆転を適用したレイヤード

上から下へ順に依存する。ドメイン層が DB を知っている。

Controller ──▶ UseCase ──▶ Domain ──▶ Repository(MySQL)
                                          ~~~~~~~~~~~~~~~~
                                          ドメインが DB を知っている

問題:
- ドメインのテストに DB が必要
- DB を変えるとドメインも変わる
- ドメインに ORM の型が染み出す
依存の向き
上から下へ一方向
ドメインの独立性
低い
テスト
DB が必要
実装の手間
少ない
  • 小規模なら十分機能する。層が分かれているだけでも、何も分かれていない状態より遥かによい。
  • ドメインのルールが複雑になってきたら、次の形への移行を検討する。

前者はドメインが DB に依存し、後者はしない。この違いが、テストの書きやすさと DB 差し替えの可否を決める。

層をまたぐときに変換する

冒頭の事故の原因は、層をまたいでも同じオブジェクトを流していたことです。

DB の行 ──▶ サービス ──▶ コントローラ ──▶ テンプレート
   del_flg, nm, crtd_at  ← ずっと同じ形のまま

これでは、DB の都合が画面まで筒抜けです。各層で変換すれば、変更が層の中に閉じます。

DB の行           →  ドメインの Entity  →  画面用の ViewModel
{del_flg, nm}        {isDeleted, name}     {displayName, canEdit}
   ↑                      ↑                      ↑
DB の都合で変わる    業務ルールで変わる      表示要件で変わる

変換のコードを書くのは面倒です。 しかしその面倒さが、「DB のカラム名を変えても画面は無傷」という性質を買っています。

確認 — ここまで読めたか

DB のカラム名を変えたら 28 ファイルが落ちました。何が起きていますか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — なぜ業務ロジックを独立させるのか

技術は変わりますが、業務ルールは変わりにくい、という非対称性があります。

  • フレームワークは5年で入れ替わる
  • DB は移行することがある
  • API の形式は REST から GraphQL へ変わるかもしれない
  • しかし「在庫がなければ売れない」は変わらない

業務ルールを技術の中に埋め込むと、技術を変えるたびに業務ルールを書き直すことになります。しかも、書き直すときに元のルールが何だったか読み取れない。フレームワークのコードと混ざっているからです。

最も価値が高く、最も長生きするものを、最も変わりやすいものから切り離す。 これがレイヤードアーキテクチャの動機です。

層の数は決まっていない

4層でなければならない理由はありません。規模に応じて変えます。

  • 小さなアプリ — コントローラとリポジトリの2層で十分なことも多い
  • 一般的な業務アプリ — 3〜4層
  • 複雑なドメイン — ドメイン層の中をさらに分けることもある

層を増やすほど、1つの処理を追うのに開くファイルが増えます。 「正しい層の数」ではなく「この規模で必要な分離はどれか」で決めます。

よくある「層の飛び越え」

// コントローラから直接リポジトリを呼ぶ
class OrderController {
  async show(req) {
    const rows = await db.query('SELECT * FROM orders WHERE id = ?', [req.params.id]);
    return rows[0];   // ← DB の行をそのまま返している
  }
}

一見すると簡潔で、層を経由するより速く書けます。しかしこれを許すと、

  • DB の構造が API のレスポンス形式になる(カラム名を変えると API が壊れる)
  • 認可やバリデーションが飛ばされる
  • 同じ取得処理が複数の場所に複製される

「単純な取得だから」という例外を1つ作ると、それが前例になって増えていきます。 単純な処理ほど、層を通す形を守っておくほうが安全です。

確認 — ここまで読めたか

コントローラから直接リポジトリを呼ぶのは、常に避けるべきですか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この実装のどこが層の設計に反していて、どう直しますか?

与えられた条件
  • 注文キャンセル機能。OrderController に以下の実装がある
  • ① req.body から orderId を取り出す
  • ② prisma.order.findUnique() で注文を取得する
  • ③ if (order.status === 'shipped') でキャンセル不可を判定し、400 を返す
  • ④ if (Date.now() - order.created_at > 7*24*3600*1000) でも 400 を返す
  • ⑤ prisma.order.update() で status を 'cancelled' にする
  • ⑥ stripe.refunds.create() で返金する
  • ⑦ 取得した order オブジェクトをそのまま JSON で返す
  • 同じキャンセル判定が、バッチ処理と管理画面にもコピーされている
この軸で考える
  • · ③④のルールは、どの層に属するべき知識か
  • · ⑦で返しているものは、誰の都合で決まった形か
  • · 同じ判定が3箇所にある状態は、何が原因で起きたか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「層に分けているのに変更が波及する」という相談に、何を見ればよいかを説明してください

与えられた条件
  • ディレクトリは層ごとに分かれている
  • DB の行オブジェクトがそのまま上位層で使われている
  • 直す順序まで示したい

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。

この記事を前提にしている記事