Spring Boot入門
3層アーキテクチャ
このレッスンでは、Controller と Service と Repository がそれぞれ何を引き受ける層なのかを説明でき、依存の向きと層をまたぐときの型の扱いを判断できるようになります。
層を分けないと何が起きるでしょうか
最初はコントローラ1つで全部書けます。動くものが早くできるので、悪い選択には見えません。
Java
@RestController
public class OrderController {
private final JdbcTemplate jdbc;
@PostMapping("/orders/{id}/ship")
public ResponseEntity<String> ship(@PathVariable Long id) {
Map<String, Object> row = jdbc.queryForMap("select * from orders where id = ?", id);
String status = (String) row.get("status");
if (!"approved".equals(status)) {
return ResponseEntity.badRequest().body("cannot ship");
}
int total = (int) row.get("total");
int fee = total >= 5000 ? 0 : 500;
jdbc.update("update orders set status = 'shipped', fee = ? where id = ?", fee, id);
return ResponseEntity.ok("shipped");
}
}この1つのメソッドの中に、性質のまったく違う3種類の仕事が同居しています。HTTP の話(パス変数の受け取り、ステータスコードの決定)、業務の話(approved でなければ出荷できない、5000 円以上は送料無料)、保存の話(SQL の発行)です。
困るのは、変更の理由が3つとも別々にやってくる点です。送料の無料ラインが 3000 円に下がる、テーブルを分割する、レスポンスの形式を JSON に変える。これらはまったく無関係な出来事なのに、同じメソッドを触ることになります。しかも「出荷できるのは approved のときだけ」という業務の決まりが、HTTP を組み立てないと呼び出せない場所に埋まっているため、単体テストを書こうとするとサーバーを起動する話になります。
3つの層と、それぞれが引き受けるもの
層を分けるとは、変更の理由ごとにクラスを分けることです。Spring Boot の定番は次の3層です。
| 層 | 主なアノテーション | 引き受けること | 引き受けないこと |
|---|---|---|---|
| Controller | @RestController | HTTP の受け口。パスとメソッドの対応、パラメータの取り出し、検証の起動、ステータスコードの決定、DTO への詰め替え | 業務ルールの判断、SQL |
| Service | @Service | 業務ルール。可否の判定、計算、複数のリポジトリ操作をまとめた一連の手続き、トランザクション境界 | HTTP の型、SQL の文字列 |
| Repository | @Repository / JpaRepository | 永続化。保存、検索、削除 | 業務ルールの判断 |
覚え方としては、それぞれの層が「知らなくてよいこと」を挙げられるかどうかが目安になります。Service は自分が HTTP から呼ばれたのかバッチから呼ばれたのかを知りません。Repository は、なぜその行を保存するのかを知りません。知らなくても仕事ができる状態が、分離できている状態です。
先ほどのコードを分けると、次のような形になります。
Java
@RestController
public class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping("/orders/{id}/ship")
public ResponseEntity<OrderResponse> ship(@PathVariable Long id) {
Order shipped = orderService.ship(id);
return ResponseEntity.ok(OrderResponse.from(shipped));
}
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public Order ship(Long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
order.ship();
return orderRepository.save(order);
}
}コントローラのメソッドが3行になりました。ここには判断が1つもありません。受け取って、渡して、返すだけです。これが健全な Controller の姿です。
依存の向きは上から下への一方向です
層の分け方と同じくらい大事なのが、どちらがどちらを知っているかという向きです。正しい向きは Controller から Service へ、Service から Repository へ、の一方向だけです。
プレーンテキスト
Controller ---> Service ---> Repository ---> DB逆向きの矢印は引きません。Service が Controller を呼ぶことも、Repository が Service を呼ぶこともありません。この一方通行を守ると、次の3つが得られます。
- 下の層だけを取り出してテストできる — Service のテストに Controller は要りません
- 下の層を差し替えられる — Repository の実装を JPA から別のものへ変えても、Service の中身は変わりません
- 循環しない — 相互に呼び合うクラスができないので、どこから読み始めればよいかが決まります
2番目を確実にするには、Service が Repository のインターフェースに依存し、実装クラスを知らない形にします。JpaRepository を継承した自作インターフェースを Service が受け取る書き方が、まさにこれです。実装は Spring が実行時に用意します。
矢印が逆を向きたくなる場面は実際にあります。たとえば Repository の中で「保存前に金額を検算したい」と思うときです。これは業務ルールなので、Repository ではなく Service か、金額を持つドメインオブジェクト自身の仕事です。矢印を逆にしたくなったら、置き場所を間違えたと考えてください。
層をまたぐときは型を乗り換えます
各層は扱う型も違います。同じ「注文」でも、層によって呼び名と形が変わります。
| 層 | 使う型 | 形を決める都合 |
|---|---|---|
| 外部との境界 | OrderRequest / OrderResponse などの DTO | クライアントが必要とするもの |
| Service | ドメインオブジェクト(Order) | 業務ルールの表現しやすさ |
| Repository | エンティティ(@Entity の付いたクラス) | テーブル設計 |
小規模なアプリではドメインオブジェクトとエンティティを同じクラスで兼ねることが多く、それは現実的な妥協として広く行われています。しかし DTO だけは分けます。理由は、レスポンスの形をテーブル設計に引きずられたくないからです。
詰め替えを書く場所にも決まりがあります。DTO からドメインへ、ドメインから DTO への変換は Controller 側で行います。Service の引数と戻り値に OrderRequest のような DTO を使ってしまうと、Service が HTTP の都合を知ってしまい、バッチや別の入口から再利用できなくなります。Service に渡すのはドメインオブジェクトか、id や金額のような素の値です。
なぜ Controller に業務ロジックを書かないのでしょうか
理由を4つに整理します。
- テストの重さが違う — 「5000 円以上は送料無料」の確認に、HTTP リクエストの組み立ては要りません。Service の static でないメソッドを直接呼べるなら、テストは1行で書けて一瞬で終わります
- 入口が増えたときに複製される — 同じ処理を管理画面からも、定期バッチからも呼びたくなったとき、ロジックが Controller にあるとコピーするしかありません。コピーした瞬間から、片方だけ直される未来が確定します
- トランザクション境界を引く場所がない —
@Transactionalは Service のメソッドに付けるのが基本です。複数の保存をまとめて成功か失敗かにしたいとき、その単位は業務の手続きの単位であり、HTTP リクエストの単位ではありません - 読む人が探す場所が定まらない — 「送料はどこで決まるのか」と聞かれたとき、Service を見れば分かる、と即答できる状態には価値があります
Controller が薄いかどうかは、次の問いで確かめられます。そのメソッドから if を全部取り除いても意味が変わらないでしょうか。取り除けないなら、その if は Service へ移すべき判断です。
この節で書いていくもの
この後のレッスンでは、Service に置くべき判断を pure Java の static メソッドとして書いていきます。Spring では @Service を付けたクラスのメソッドとして書き、DI で受け取って呼ぶものですが、中身の判断そのものはフレームワークとは無関係な素の Java です。逆に言えば、その部分だけを取り出して書けるということ自体が、層が分かれている証拠でもあります。