【Spring Boot】Spring Bootにおけるレイヤードアーキテクチャの考え方

Java

こんにちは、かつコーチです。

Controllerに直接SQLを書いてしまう、Serviceが肥大化して1000行を超える、DTOとEntityが混在して依存が絡み合う。

こうした問題の多くは、レイヤードアーキテクチャ(責務ごとに層を分け、上位層が下位層のみに依存する設計原則)が徹底されていないことに起因します。

今回は、Spring Bootでレイヤードアーキテクチャをどう実践するか、循環参照の記事でも触れた責務分割の考え方をさらに掘り下げて整理します。

レイヤードアーキテクチャの基本構造

各層の責務

Spring Bootのレイヤードアーキテクチャは、一般的に次の4層で構成します。

役割対応するアノテーション
Presentation層リクエスト受付・レスポンス整形@RestController
Application(Service)層ユースケースの実現・トランザクション制御@Service
Domain層ビジネスルール・ドメインモデルPOJO(Entity等)
Infrastructure(Repository)層データ永続化・外部API連携@Repository

原則は「上位層は下位層に依存してよいが、下位層は上位層に依存してはならない」という一方向の依存です。

RepositoryServiceを呼び出すような逆方向の依存が発生していたら、それはレイヤードアーキテクチャが崩れているサインです。

なぜ層を分けるのか

層を分ける最大の目的は、変更の影響範囲を局所化することです。

DBをMySQLからPostgreSQLに変えたい、認証方式をJWTからOAuth2に変えたいといった変更が発生したとき、層が適切に分離されていれば、影響はInfrastructure層かPresentation層の一部に留まります。

逆に、Controllerの中でRepositoryを直接呼び出しているようなコードでは、変更のたびにController・Service・Repositoryを横断的に修正することになり、テストもしにくくなります。

Controller・Service・Repositoryの責務分離

Controllerに書いてはいけないこと

❌ Before:Controllerにビジネスロジックが漏れている

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderRepository orderRepository;
    private final StockRepository stockRepository;

    public OrderController(OrderRepository orderRepository, StockRepository stockRepository) {
        this.orderRepository = orderRepository;
        this.stockRepository = stockRepository;
    }

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody OrderRequest request) {
        Stock stock = stockRepository.findByProductId(request.getProductId());
        if (stock.getQuantity() < request.getQuantity()) {
            throw new IllegalStateException("在庫が不足しています");
        }
        stock.setQuantity(stock.getQuantity() - request.getQuantity());
        stockRepository.save(stock);

        Order order = new Order(request.getProductId(), request.getQuantity());
        return ResponseEntity.ok(orderRepository.save(order));
    }
}

在庫チェックというビジネスルールがControllerに直接書かれており、OrderControllerが2つのRepositoryに依存してしまっています。

このコードをテストするには、HTTPリクエストを模したテストを書く必要があり、ビジネスロジック単体のテストがしづらくなります。

✅ After:ビジネスロジックをServiceに集約する

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody OrderRequest request) {
        Order order = orderService.placeOrder(request.getProductId(), request.getQuantity());
        return ResponseEntity.ok(order);
    }
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final StockService stockService;

    public OrderService(OrderRepository orderRepository, StockService stockService) {
        this.orderRepository = orderRepository;
        this.stockService = stockService;
    }

    @Transactional
    public Order placeOrder(Long productId, int quantity) {
        stockService.decrease(productId, quantity);
        return orderRepository.save(new Order(productId, quantity));
    }
}

OrderControllerは「リクエストを受け取り、Serviceに委譲し、レスポンスを返す」だけの薄い層になりました。

在庫チェックのロジックが変わっても、OrderServiceStockServiceだけを見ればよく、Controllerには一切手を入れる必要がありません。

トランザクション境界はService層に置く

@TransactionalはController層ではなく、Service層のpublicメソッドに付けるのが基本です。

Controllerに@Transactionalを付けてしまうと、HTTP通信という重い処理を含んだままトランザクションが開始されてしまい、DBコネクションを不必要に長く保持することになります。

placeOrder()のように、複数のRepository操作をまたぐ処理こそ、Service層でトランザクションとしてまとめる価値があります。

DTOとEntityの境界を守る

なぜController外にEntityを漏らさないのか

以前の記事でDTOとEntityの使い分けを紹介しましたが、レイヤードアーキテクチャの文脈では「Entityをレスポンスとしてそのまま返さない」という原則がより重要になります。

Entityをそのまま@RestControllerのレスポンスにすると、JPAの遅延ロード設定によっては意図しない関連データまでシリアライズされてしまい、LazyInitializationException(トランザクション終了後に遅延ロードされたフィールドへアクセスしようとして発生する例外)を引き起こすことがあります。

@Service
public class OrderService {
    @Transactional(readOnly = true)
    public OrderResponse findById(Long id) {
        Order order = orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
        // EntityをDTOに変換してから返す
        return new OrderResponse(order.getId(), order.getProductId(), order.getQuantity());
    }
}

Service層の内部でEntityからDTOへの変換を完結させ、Presentation層にはEntityを一切渡さないことで、DBの内部構造がAPIレスポンスの形として漏れ出るのを防げます。

まとめ

この記事のポイント

  • レイヤードアーキテクチャはPresentation・Service・Domain・Infrastructureの4層で構成し、上位層から下位層への一方向依存を守る
  • Controllerはリクエスト受付とレスポンス整形に専念し、ビジネスロジックはServiceに集約する
  • @TransactionalはController層ではなくService層のpublicメソッドに付ける
  • EntityをそのままAPIレスポンスとして返さず、DTOへの変換をService層で完結させる

次に読むべき記事

  • DTOとEntityを使い分ける設計の考え方
  • 循環参照エラーの原因と解決法
  • @ControllerAdviceで例外ハンドリングを一元化する

タグ: Spring Boot, 上級者向け, 設計

タイトルとURLをコピーしました