レイヤードアーキテクチャ — 依存の向きを一方向に揃える
層に分けること自体に価値はない。価値があるのは、依存が一方向であることと、業務ロジックが技術から独立すること。
先に読んでおくとよい記事
この記事の進み方
What — 層と、その間を流れるもの
上から下への一方向依存。そして層をまたぐたびに、データはその層の言葉に変換される。変換を省くと冒頭の事故になる。
依存の向きと、データの向き
ここが最も混乱するところです。依存の向きとデータの流れは別物です。
上から下へ順に依存する。ドメイン層が 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 ファイルが落ちました。何が起きていますか?
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つ作ると、それが前例になって増えていきます。 単純な処理ほど、層を通す形を守っておくほうが安全です。
コントローラから直接リポジトリを呼ぶのは、常に避けるべきですか?
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
この実装のどこが層の設計に反していて、どう直しますか?
- 注文キャンセル機能。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箇所にある状態は、何が原因で起きたか
「層に分けているのに変更が波及する」という相談に、何を見ればよいかを説明してください
- ディレクトリは層ごとに分かれている
- DB の行オブジェクトがそのまま上位層で使われている
- 直す順序まで示したい
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。