Skip to content
Go back

レイヤードアーキテクチャ実践ガイド:Controller から Repository への直接依存を解消する

はじめに

本記事では、Spring Boot アプリケーションにおける レイヤードアーキテクチャ違反 を修正するリファクタリング事例を通じて、適切なレイヤー設計とその実装方法を解説します。

「Controller が Repository に直接アクセスしている」というコードを見たことはありませんか?一見動作するこのコードが、なぜ問題なのか、そしてどのように修正すべきかを、実際のコード例とともに詳しく説明します。

この記事で学べること

対象読者


1. リファクタリングの背景

問題のあるコード

以下は、提案書(Proposal)のステータスを取得する API エンドポイントのコードです。

// ❌ Before: Controller が Repository に直接依存している
@RestController
@RequestMapping("/api/v1/proposals")
class ProposalController(
    private val proposalService: ProposalService,
    private val proposalRepository: ProposalRepository  // ← 問題: Repository に直接依存
) {

    @GetMapping("/{proposalId}/status")
    fun getProposalStatus(
        @PathVariable proposalId: String,
        @RequestAttribute("userId") userId: String
    ): ResponseEntity<ProposalStatusResponse> {
        // ❌ Controller から Repository を直接呼び出している
        val proposal = proposalRepository.findByIdAndUserId(proposalId, userId)
            ?: return ResponseEntity.notFound().build()

        return ResponseEntity.ok(
            ProposalStatusResponse(
                id = proposal.id,
                status = proposal.status,
                // ...
            )
        )
    }
}

このコードは 動作します 。テストも通るかもしれません。では、何が問題なのでしょうか?


2. レイヤードアーキテクチャとは

2.1 基本構造

レイヤードアーキテクチャは、アプリケーションを 責務ごとに階層化 する設計パターンです。

flowchart TD
    subgraph Presentation["Presentation Layer"]
        C[Controller]
        C1["HTTP リクエスト/レスポンス処理"]
    end

    subgraph Business["Business Layer"]
        S[Service]
        S1["ビジネスロジック、ユースケース"]
    end

    subgraph Persistence["Persistence Layer"]
        R[Repository]
        R1["データの永続化、DB アクセス"]
    end

    DB[(Database)]

    Presentation -->|依存| Business
    Business -->|依存| Persistence
    Persistence --> DB

2.2 各レイヤーの責務

レイヤー責務Spring でのクラス
PresentationHTTP の処理、リクエスト/レスポンスの変換、入力バリデーション@RestController
Businessビジネスロジック、トランザクション管理、ユースケースの実装@Service
Persistenceデータの永続化、DB クエリの実行、エンティティの CRUD@Repository

2.3 重要なルール:依存の方向は上から下のみ

flowchart LR
    subgraph OK["✅ 正しい依存"]
        C1[Controller] --> S1[Service] --> R1[Repository]
    end

    subgraph NG["❌ レイヤー違反"]
        C2[Controller] -.->|直接依存| R2[Repository]
    end

上位レイヤーは下位レイヤーに依存できますが、その逆は許されません。 また、 レイヤーを飛び越えた依存も避けるべきです。


3. なぜ Controller から Repository への直接依存が問題なのか

3.1 問題点一覧

問題具体例影響
レイヤー責務の曖昧化ビジネスロジックが Controller に漏れ出す保守性の低下
テスタビリティの低下Controller テストで Repository のモックが必要テストが複雑化
変更の影響範囲拡大Repository の変更が Controller に直接影響修正コストの増加
再利用性の低下同じ DB アクセスロジックが複数箇所に分散DRY 原則違反
トランザクション管理の困難Service を介さないため、トランザクション境界が不明確データ整合性のリスク

3.2 具体的なシナリオで考える

シナリオ 1: 認可ロジックの追加

「管理者ユーザーは全ての Proposal を見れるようにしたい」という要件が追加されたとします。

Controller に Repository 依存がある場合:

// ❌ Controller にビジネスロジックが漏れ出す
@GetMapping("/{proposalId}/status")
fun getProposalStatus(
    @PathVariable proposalId: String,
    @RequestAttribute("userId") userId: String,
    @RequestAttribute("userRole") userRole: String  // 新しく追加
): ResponseEntity<ProposalStatusResponse> {
    // ビジネスロジックが Controller に...
    val proposal = if (userRole == "ADMIN") {
        proposalRepository.findById(proposalId).orElse(null)
    } else {
        proposalRepository.findByIdAndUserId(proposalId, userId)
    }
    // ...
}

Service を介する場合:

// ✅ Controller はシンプルなまま
@GetMapping("/{proposalId}/status")
fun getProposalStatus(
    @PathVariable proposalId: String,
    @RequestAttribute("userId") userId: String
): ResponseEntity<ProposalStatusResponse> {
    val proposal = proposalService.getProposalForUser(proposalId, userId)
        ?: return ResponseEntity.notFound().build()
    // ...
}

// ✅ ビジネスロジックは Service に集約
@Service
class ProposalService {
    fun getProposalForUser(proposalId: String, userId: String): Proposal? {
        // 認可ロジックはここで一元管理
        val user = userService.getUser(userId)
        return if (user.isAdmin) {
            proposalRepository.findById(proposalId).orElse(null)
        } else {
            proposalRepository.findByIdAndUserId(proposalId, userId)
        }
    }
}

シナリオ 2: キャッシュの追加

「頻繁にアクセスされる Proposal はキャッシュしたい」という要件が追加されたとします。

Service を介する場合、Controller を変更せずに対応可能:

@Service
class ProposalService {
    @Cacheable("proposals")  // キャッシュ追加
    fun getProposalForUser(proposalId: String, userId: String): Proposal? {
        return proposalRepository.findByIdAndUserId(proposalId, userId)
    }
}

Controller は一切変更不要です。これが レイヤー分離の恩恵 です。


4. SOLID 原則との関連

4.1 依存性逆転の原則(DIP: Dependency Inversion Principle)

上位モジュールは下位モジュールに依存してはならない。両者は抽象に依存すべきである。

flowchart LR
    subgraph violation["❌ 違反パターン"]
        C1[Controller] -->|直接依存| R1[Repository]
    end
flowchart TD
    subgraph correct["✅ 正しいパターン"]
        C2[Controller] -->|依存| SI[Service Interface]
        SI -->|使用| IMPL[Service Impl]
        IMPL -->|依存| R2[Repository]
    end

4.2 単一責任の原則(SRP: Single Responsibility Principle)

クラスを変更する理由は1つだけであるべき

Controller を変更する理由:

Repository を変更する理由:

これらは別の変更理由であり、同じクラスが両方に依存すべきではありません。


5. TDD によるリファクタリング実践

5.1 TDD の基本サイクル

flowchart LR
    RED["🔴 RED<br/>テストを書く<br/>(失敗する)"]
    GREEN["🟢 GREEN<br/>最小限の実装で<br/>テストを通す"]
    REFACTOR["🔵 REFACTOR<br/>コードを改善<br/>(テストは緑)"]

    RED --> GREEN --> REFACTOR --> RED

5.2 Step 1: RED - 失敗するテストを書く

まず、新しい Service メソッドのテストを書きます。

@Nested
@DisplayName("getProposalForUser")
inner class GetProposalForUserTest {

    @Test
    @DisplayName("正常系: proposalId と userId が一致する Proposal を返す")
    fun `proposalId と userId が一致する Proposal を返す`() {
        // Arrange(準備)
        val proposalId = "test-proposal-id"
        val userId = "test-user-id"
        val expectedProposal = createProposal(id = proposalId, userId = userId)

        // モックの設定: Repository が呼ばれたら expectedProposal を返す
        every {
            mockProposalRepository.findByIdAndUserId(proposalId, userId)
        } returns expectedProposal

        // Act(実行)
        val result = service.getProposalForUser(proposalId, userId)

        // Assert(検証)
        assertEquals(expectedProposal, result)
        verify(exactly = 1) {
            mockProposalRepository.findByIdAndUserId(proposalId, userId)
        }
    }

    @Test
    @DisplayName("異常系: Proposal が見つからない場合 null を返す")
    fun `Proposal が見つからない場合 null を返す`() {
        // Arrange
        val proposalId = "non-existent-proposal"
        val userId = "test-user-id"
        every {
            mockProposalRepository.findByIdAndUserId(proposalId, userId)
        } returns null

        // Act
        val result = service.getProposalForUser(proposalId, userId)

        // Assert
        assertNull(result)
        verify(exactly = 1) {
            mockProposalRepository.findByIdAndUserId(proposalId, userId)
        }
    }

    @Test
    @DisplayName("異常系: userId が異なる場合 null を返す")
    fun `userId が異なる場合 null を返す`() {
        // Arrange
        val proposalId = "test-proposal-id"
        val requestingUserId = "different-user-id"
        every {
            mockProposalRepository.findByIdAndUserId(proposalId, requestingUserId)
        } returns null

        // Act
        val result = service.getProposalForUser(proposalId, requestingUserId)

        // Assert
        assertNull(result)
    }
}

この時点でテストを実行すると、getProposalForUser メソッドが存在しないため コンパイルエラー になります。これが RED(失敗)の状態です。

5.3 Step 2: GREEN - 最小限の実装

テストを通すために、Service にメソッドを追加します。

@Service
class ProposalService(
    private val proposalRepository: ProposalRepository,
    // ... 他の依存
) {
    /**
     * ユーザーに紐づく Proposal を取得する
     *
     * @param proposalId Proposal の ID
     * @param userId ユーザー ID
     * @return Proposal(見つからない場合または userId が一致しない場合は null)
     */
    fun getProposalForUser(proposalId: String, userId: String): Proposal? {
        return proposalRepository.findByIdAndUserId(proposalId, userId)
    }

    // ... 既存のメソッド
}

テストを実行すると、すべて グリーン(成功) になります。

5.4 Step 3: REFACTOR - Controller の修正

テストが通ったので、Controller から Repository への依存を削除します。

// ✅ After: Controller は Service のみに依存
@RestController
@RequestMapping("/api/v1/proposals")
class ProposalController(
    private val proposalService: ProposalService
    // proposalRepository は削除!
) {

    @GetMapping("/{proposalId}/status")
    fun getProposalStatus(
        @PathVariable proposalId: String,
        @RequestAttribute("userId") userId: String
    ): ResponseEntity<ProposalStatusResponse> {
        // ✅ Service を経由して取得
        val proposal = proposalService.getProposalForUser(proposalId, userId)
            ?: return ResponseEntity.notFound().build()

        return ResponseEntity.ok(
            ProposalStatusResponse(
                id = proposal.id,
                status = proposal.status,
                // ...
            )
        )
    }
}

6. Before / After 比較

6.1 依存関係の変化

flowchart TD
    subgraph before["❌ Before"]
        C1[ProposalController]
        S1[Service]
        R1[Repository]
        C1 --> S1
        C1 -.->|レイヤー違反| R1
    end
flowchart TD
    subgraph after["✅ After"]
        C2[ProposalController]
        S2[ProposalService]
        R2[ProposalRepository]
        C2 --> S2 --> R2
    end

6.2 コードの変化

Controller(import 文)

- import com.example.app.repository.ProposalRepository
  import com.example.app.service.ProposalService

Controller(コンストラクタ)

  class ProposalController(
-     private val proposalService: ProposalService,
-     private val proposalRepository: ProposalRepository
+     private val proposalService: ProposalService
  )

Controller(メソッド内)

  fun getProposalStatus(/* ... */): ResponseEntity<ProposalStatusResponse> {
-     val proposal = proposalRepository.findByIdAndUserId(proposalId, userId)
+     val proposal = proposalService.getProposalForUser(proposalId, userId)
          ?: return ResponseEntity.notFound().build()

Service(新規メソッド追加)

/**
 * ユーザーに紐づく Proposal を取得する
 *
 * @param proposalId Proposal の ID
 * @param userId ユーザー ID
 * @return Proposal(見つからない場合または userId が一致しない場合は null)
 */
fun getProposalForUser(proposalId: String, userId: String): Proposal? {
    return proposalRepository.findByIdAndUserId(proposalId, userId)
}

6.3 追加ボーナス: ハードコード値の削除

リファクタリングついでに発見した改善点も修正しました。

  ResponseEntity.status(HttpStatus.CREATED).body(
      ProposalResponse(
          id = proposal.id,
-         status = ProposalStatus.PENDING,  // ハードコード
+         status = proposal.status,         // エンティティの値を使用
          message = "提案生成リクエストを受け付けました。"
      )
  )

7. テストの書き方解説

7.1 MockK を使ったモック設定

// テストクラスのセットアップ
class ProposalServiceTest {

    private lateinit var service: ProposalService

    // MockK でモックを作成(relaxed = true で未設定メソッドもデフォルト値を返す)
    private val mockProposalRepository: ProposalRepository = mockk(relaxed = true)
    private val mockLambdaInvocationService: LambdaInvocationService = mockk(relaxed = true)

    @BeforeEach
    fun setup() {
        // テスト対象のサービスを初期化(モックを注入)
        service = ProposalService(
            mockProposalRepository,
            mockLambdaInvocationService,
            // ...
        )
    }
}

7.2 Arrange-Act-Assert パターン

テストは以下の3つのセクションで構成します:

@Test
fun `テストケース名`() {
    // ============================================
    // Arrange(準備): テストに必要なデータとモックを設定
    // ============================================
    val proposalId = "test-proposal-id"
    val userId = "test-user-id"
    val expectedProposal = createProposal(id = proposalId, userId = userId)

    every {
        mockProposalRepository.findByIdAndUserId(proposalId, userId)
    } returns expectedProposal

    // ============================================
    // Act(実行): テスト対象のメソッドを呼び出す
    // ============================================
    val result = service.getProposalForUser(proposalId, userId)

    // ============================================
    // Assert(検証): 期待する結果を検証
    // ============================================
    assertEquals(expectedProposal, result)
    verify(exactly = 1) {
        mockProposalRepository.findByIdAndUserId(proposalId, userId)
    }
}

7.3 テストケースの設計指針

カテゴリテストすべきこと
正常系期待通りの入力で期待通りの出力ID と userId が一致 → Proposal を返す
異常系(存在しない)リソースが存在しない場合存在しない ID → null を返す
異常系(認可)権限がない場合他人の userId → null を返す
境界値特殊な入力値空文字、null、最大長など

8. よくある質問

Q1: 「Service が Repository をそのまま呼ぶだけ」でも意味があるの?

A: はい、意味があります。

一見「パススルー」に見えるメソッドでも、以下のメリットがあります:

  1. 将来の拡張性: 後でキャッシュ、ログ、認可ロジックを追加しやすい
  2. レイヤーの一貫性: 全てのデータアクセスが Service を経由する規約
  3. テスタビリティ: Controller のテストで Repository のモックが不要

Q2: 全ての Repository メソッドに対応する Service メソッドが必要?

A: いいえ、必要に応じて追加します。

Controller から使用する Repository メソッドのみ、Service にラッパーメソッドを用意します。Service 内部でのみ使う Repository メソッドはそのままで OK です。

Q3: Repository への依存は常に悪?

A: Controller から Repository への直接依存は避けるべきですが、Service から Repository への依存は正常です。

flowchart LR
    subgraph correct["✅ 正しいパターン"]
        C[Controller] --> S[Service] --> R[Repository]
    end

9. まとめ

リファクタリングで達成したこと

BeforeAfter
Controller が Repository に直接依存Controller は Service のみに依存
レイヤードアーキテクチャ違反正しいレイヤー構造
ビジネスロジックが分散Service に集約
テストで Repository モックが必要Service のモックのみで OK

学んだ原則

  1. レイヤードアーキテクチャ: 依存の方向は上から下のみ
  2. 依存性逆転の原則(DIP): 具象ではなく抽象に依存
  3. 単一責任の原則(SRP): 変更理由は1つだけ
  4. TDD: テストファースト → 実装 → リファクタリング

チェックリスト

次のコードレビューで確認すべきポイント:


参考資料


Share this post on:

Previous Post
Spring Boot サービス層リファクタリング実践ガイド
Next Post
SOLID原則によるKotlinコードリファクタリング実践ガイド