はじめに
仕様変更のたびに、直すべきファイルを見落として修正漏れが起きる。
あるいは、業務ルールの一部が API のハンドラや SQL の近くに散らばり、同じ条件が複数箇所にコピーされている。
コードベースが成長すると、このような失敗は個人の注意力だけでは防ぎきれません。
業務ロジックを レイヤードアーキテクチャ で層に分けることで、責務の置き場所をコード上で固定することで、長期間管理運用していく必要があるコードの管理難易度を下げることができます。
「どこを直せばよいか」が構造から読み取れると、半年後の自分や新しく参加したメンバーが変更範囲を追いやすくなります。
この記事で得られる視点
- レイヤードアーキテクチャの各層の責務と依存の向き
- 業務ロジックが UI や永続化に漏れるときのリスク
- レイヤー分割が向くケースと、アンチパターンの見分け方
なぜレイヤーで分けるのか
業務ロジックは、ソフトウェアの中で最も変更理由が多い部分です。
一方で、画面の見た目やデータベースの保存形式は、業務ルールとは別の理由で変わります。
これらを同じファイルに書くと、次のような問題が起きやすくなります。
- 在庫チェックの条件を直したが、別 API のバリデーションを見落とした
- テーブル構造の変更が、画面表示のロジックまで巻き込んだ
- 単体テストで業務ルールを検証するために、毎回 DB を用意する必要がある
レイヤーは、変更理由ごとにコードを分ける引き出しのような役割です。
引き出しごとに入れるものが決まっていれば、どこを探せばよいか迷いにくくなります。
同じように、境界がはっきりしていれば、レビューで「業務ルールの変更はドメイン層だけ」と判断しやすくなります。
レイヤードアーキテクチャとは
レイヤードアーキテクチャは、ソフトウェアを複数の層に分け、上の層が下の層にだけ依存する 構造です。
下の層は上の層を知りません。業務ルールはドメイン層に集約し、UI や DB などの技術的な詳細は上下の層に分けます。
本稿では、クリーンアーキテクチャのように依存を逆転させるのではなく、層が上から下へ積み上がる単純なレイヤード として説明します。
矢印は「依存の向き」を表します。
各層は原則として 真下の層だけ を呼び出し、依存は上から下へ流れます。
アプリケーション層とサービス層は名前が似ていますが、前者はユースケースの入口、後者は業務処理の手順を担う 別の層 です。
| 層 | 主な責務 | 置くものの例 |
|---|---|---|
| プレゼンテーション層 | 入出力の変換、ユーザー操作の受け口 | コントローラ、画面コンポーネント、リクエスト検証 |
| アプリケーション層 | ユースケースの入口、要求の振り分け | ユースケースクラス、Application Facade |
| サービス層 | 業務処理の手順、ドメイン層の操作の組み立て | OrderService、トランザクション境界 |
| 業務ロジック層 | 業務ルールそのもの | ドメインモデル、値オブジェクト、ドメインサービス |
| データアクセス層 | 永続化の具体手段 | リポジトリ、ORM マッピング、SQL |
各層の責務
プレゼンテーション層
HTTP リクエストや画面イベントを受け取り、アプリケーション層を呼び出す層です。
「JSON の形に合わせる」「ステータスコードを返す」といった 入出力の都合 を扱います。
業務ルール(在庫が足りなければ注文不可、など)はここに書かないのが原則です。
プレゼンテーション層に業務判断が入ると、別の API やバッチから同じルールを再利用しづらくなります。
アプリケーション層
「どのユースケースを実行するか」を決める、薄い入口 の層です。
プレゼンテーション層から渡されたパラメータを受け取り、対応するサービス層のメソッドを呼び出します。
業務ルールや永続化の手順はここに書きません。
アプリケーション層に処理が溜まると、サービス層との境界が曖昧になり、同じ手順が別の入口から重複しやすくなります。
サービス層
ドメイン層のオブジェクトを組み合わせ、業務処理の手順 を実装する層です。
「注文を作成して確定し、保存する」といった一連の流れや、トランザクションの開始・終了をここに置きます。
業務ルールそのものはドメイン層に残し、サービス層は「いつ・何を呼ぶか」に専念するのが一般的です。
ルールをサービス層に if 文で書き足すと、トランザクションスクリプトと同様、手続きが肥大化しやすくなります。
業務ロジック層(ドメイン層)
ソフトウェアの核となる層です。
「この数量は登録できない」「この状態からは遷移できない」といった 業務上の制約 をここに集約します。
ドメインモデルや値オブジェクトを使う場合も、この層が主な置き場所になります。
アクティブレコードを採用する場合でも、永続化の詳細と業務ルールの境界を意識して分ける判断が必要です。
データアクセス層
データベースや外部ストレージへの読み書きを担当します。
テーブル名、カラム型、ORM の設定など 保存の仕組み を扱います。
ドメイン層から呼び出され、ドメインオブジェクトの読み書きを SQL や ORM マッピングで行います。
保存形式の変更は、この層の修正に閉じ込められるのが理想です。
サンプルコード
注文作成の責務分担のイメージです。
業務ルールは Order に閉じ、手順はサービス層、入口はアプリケーション層に分けています。
/** 業務ロジック層:注文の不変条件を守る */
export class Order {
private constructor(
private readonly id: string,
private readonly lines: readonly OrderLine[],
private status: 'draft' | 'placed',
) {}
static create(id: string, lines: readonly OrderLine[]): Order {
if (lines.length === 0) {
throw new Error('明細が空の注文は作成できません');
}
return new Order(id, lines, 'draft');
}
place(): void {
if (this.status === 'placed') {
throw new Error('すでに確定済みです');
}
this.status = 'placed';
}
}
/** データアクセス層:永続化の具体 */
export interface OrderRepository {
save(order: Order): Promise<void>;
}
/** ドメイン層:確定した注文の永続化をデータアクセス層に委譲 */
export class OrderPersister {
constructor(private readonly orders: OrderRepository) {}
async save(order: Order): Promise<void> {
await this.orders.save(order);
}
}
/** サービス層:業務処理の手順 */
export class OrderService {
constructor(private readonly persister: OrderPersister) {}
async placeOrder(orderId: string, lines: readonly OrderLine[]): Promise<void> {
const order = Order.create(orderId, lines);
order.place();
await this.persister.save(order);
}
}
/** アプリケーション層:ユースケースの入口 */
export class PlaceOrderUseCase {
constructor(private readonly orderService: OrderService) {}
async execute(orderId: string, lines: readonly OrderLine[]): Promise<void> {
await this.orderService.placeOrder(orderId, lines);
}
}
Order はリポジトリの存在を知りません。
PlaceOrderUseCase → OrderService → Order / OrderPersister → OrderRepository の順に、上の層が真下の層だけを呼び出す構成です。
業務ルールのテストは Order だけで実行できます。
アンチパターン
- コントローラに在庫チェックを書く:同じ条件が別エンドポイントにコピーされ、修正漏れの温床になる
- アプリケーション層とサービス層の責務を混同する:入口と業務手順が同一クラスに集まり、層の意味が失われる
- ドメイン層から ORM を直接 import する:テーブル変更が業務ルールのテストまで壊す
- サービス層に if 文の業務判断を集める:手続きが長くなり、ドメインモデルが薄くなる
- 層の存在を口頭ルールだけで運用する:レビュー依存になり、境界違反が静かに増える
個人の注意に頼るより、ディレクトリ構成や Lint ルールで import を制限するほうが、チームでは堅牢です。
私は、ディレクトリ単位で import を制限する Lint 設定を併用し、レビューだけに頼らない運用にしています。
レイヤードアーキテクチャに向いているケース
- 業務ルールが複数の入口(画面・API・バッチ)から共有される
- 永続化方式や UI フレームワークの変更が想定される
- チームで「業務の変更はどの層か」を共通認識にしたい
向いていない・別の検討が必要なケース
- ドメインが極めて単純で、CRUD だけで完結する小規模ツール
- 中核ドメインが非常に複雑で、層の内側だけでは表現しきれない(ポートとアダプタを明示する設計の検討)
- 最初から過度な層分けを行い、変更のたびに複数ファイルを行き来する手間が利益を上回る
レイヤーは 目的 ではなく 手段 です。
複雑さに見合った分だけ層を設け、不要な抽象化は避けるのが現実的です。
まとめ
レイヤードアーキテクチャは、業務ロジック・入出力・永続化の 変更理由 を分離するための構造です。
上の層が下の層にだけ依存する一方通行を守ると、業務ルールの所在がコードから読み取りやすくなります。
私は、修正漏れを減らすために「注意喚起」だけでなく、層ごとの責務と import の制約をセットで設計することを優先しています。
次に仕様変更が入るとき、まず「この変更はどの層の責務か」を確認する習慣から始めると、整理の効果を実感しやすくなります。
参考文献
- ドメイン駆動設計をはじめよう(著:Vlad Khononov 訳:増田 亭、綿引 琢磨)