Skip to content
Playground

9.サービスとリポジトリ

学習目標

  • @Repository@Service と DI(依存性の注入)で、Controller・Service・Repository を組み立てられる
  • 接続情報を application.properties に集約できる
  • ビジネスロジックを Service に集約する理由を説明できる
  • レイヤードアーキテクチャの依存の向きと、変更が 1 つの層に収まる利点を説明できる

データアクセスの分離 で、BookController の JDBC を BookRepository に移しました。ただし BookControllerBookRepositorynew で作り、接続情報は BookRepository のコードに直接書いたままです。本の登録で必要になるビジネスルール(入力の検証など)にも、置き場所がありません。

本章では、BookRepository を Spring Boot に生成・注入させ(DI)、接続情報を 1 か所にまとめます。さらに、ビジネスロジックを担う Service を加え、BookControllerBookServiceBookRepository の 3 層に整えます。

データの取得・保存(データアクセス)を担うクラスを Repository(リポジトリ)と呼びます。データアクセスの分離 で切り出した BookRepository がそれにあたり、SQL や接続の詳細を閉じ込め、ほかのクラスは findAll のようなメソッドで呼び出します。

ただし、BookController はその BookRepositorynew で自分で作っています。

BookController.java(8 章の状態)
@Controller
public class BookController {
private final BookRepository bookRepository = new BookRepository();
// books メソッドは省略
}

new で作ると、BookController 自身が BookRepository の生成を担います。本節では、この生成を Spring Boot に任せて new をやめ、接続情報も 1 か所にまとめます。

BookRepository の生成を Spring Boot に任せるため、クラスに @Repository を付けます。@Repository を付けたクラスは、Spring Boot が起動時にインスタンスを生成して管理します。

BookRepository.java
@Repository
public class BookRepository {
// findAll は 8 章のまま
}

BookController は、new の代わりにコンストラクターの引数で BookRepository を受け取ります。

BookController.java
package com.example.bookstore;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class BookController {
private final BookRepository bookRepository;
public BookController(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@GetMapping("/books")
public String books(Model model) {
model.addAttribute("books", bookRepository.findAll());
return "books";
}
}

new BookRepository() はコードから消えました。@Controller の付いた BookController も Spring Boot が生成し、その際にコンストラクターの引数へ BookRepository のインスタンスを渡します。

Spring Boot を使わずに手で組み立てるなら、依存を new してコンストラクターに渡します。これは コンポジションMain が行っていたことと同じです。

// Spring を使わずに手で組み立てる場合(実際には書かない)
BookRepository repository = new BookRepository();
BookController controller = new BookController(repository);

Spring Boot のアプリケーションには、この組み立てを書く Main がありません。代わりに、Spring Boot が起動時に同じ組み立てを自動で行います。

  1. クラスを見つける

    @SpringBootApplication に含まれるコンポーネントスキャンが、@Controller@Repository の付いたクラスを探します。

  2. 依存をたどって生成する

    BookController のコンストラクターの引数に BookRepository があるため、Spring Boot は BookRepository を生成します。

  3. 注入する

    生成した BookRepository を、BookController のコンストラクターに渡します。

BookController への注入と同じことを、BookRepository の接続にも適用します。これまでの BookRepository は、DriverManager.getConnection(url, "root", "root") で接続を作り、接続先の情報をコードに直接書いていました。この接続情報を application.properties にまとめ、接続そのものを Spring Boot から受け取る形にします。

まず、Spring Boot が JDBC の接続を組み立てられるよう、spring-boot-starter-jdbc を依存関係に追加します。

pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

接続先の情報は application.properties に書きます。

application.properties
spring.datasource.url=jdbc:mysql://127.0.0.1:3307/bookstore
spring.datasource.username=root
spring.datasource.password=root

spring-boot-starter-jdbc を追加して接続情報を書くと、Spring Boot は接続を供給する DataSource を自動で組み立てます。BookRepository は、この DataSource をコンストラクターで受け取ります。

BookRepository.java
package com.example.bookstore;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import org.springframework.stereotype.Repository;
@Repository
public class BookRepository {
private final DataSource dataSource;
public BookRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
public List<Book> findAll() {
List<Book> books = new ArrayList<>();
String sql = "SELECT title, price, stock FROM books";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
books.add(new Book(rs.getString("title"), rs.getInt("price"), rs.getInt("stock")));
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return books;
}
}

Spring Boot は、BookControllerBookRepository を注入したのと同じ仕組みで、BookRepositoryDataSource を注入します。DriverManager で毎回接続を作る代わりに dataSource.getConnection() で接続を得て、接続先を変えるときに直すのは application.properties です。

RepositoryBookRepository を Spring Boot に組み込みました。次は本の登録です。登録には「価格は 100 以上」「同じタイトルは登録しない」というビジネスルールがあります。

登録のルールを担う層を、BookControllerBookRepository の間に置きます。この層が Service(サービス)です。Service はルールを確認してから、BookRepository に保存を任せます。

価格と重複の 2 つのルールを、BookServiceregister メソッドに実装します。

src/main/java/com/example/bookstore/BookService.java
package com.example.bookstore;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public List<Book> findAll() {
return bookRepository.findAll();
}
public void register(BookForm form) {
// ビジネスルール 1: 価格は 100 以上
if (form.getPrice() < 100) {
throw new IllegalArgumentException("価格は 100 以上で指定してください");
}
// ビジネスルール 2: 同じタイトルは登録しない
if (bookRepository.existsByTitle(form.getTitle())) {
throw new IllegalStateException("同じタイトルの本がすでに登録されています");
}
bookRepository.save(new Book(form.getTitle(), form.getPrice(), 0));
}
}

BookService に付けた @Service は、@Repository と同じく Spring Boot に登録するアノテーションで、ビジネスロジックのクラスに付けます。BookServiceBookRepository をコンストラクターで受け取ります。register は、価格を検証し、重複を確かめてから保存します。新規に登録する本の在庫は 0 から始めます。

register が呼ぶ existsByTitle(同名の本の有無)と save(1 冊の保存)は、まだ BookRepository にありません。どちらもデータベースへの操作なので、BookRepository に追加します。

BookRepository.java(メソッドを追加)
public void save(Book book) {
String sql = "INSERT INTO books (title, price, stock) VALUES (?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, book.getTitle());
stmt.setInt(2, book.getPrice());
stmt.setInt(3, book.getStock());
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public boolean existsByTitle(String title) {
String sql = "SELECT COUNT(*) FROM books WHERE title = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, title);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
return rs.getInt(1) > 0;
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
}

save は INSERT で 1 冊を保存し、existsByTitleSELECT COUNT(*) で同名の本の件数を数えて有無を返します。register は、この 2 つを使ってルールを確認し、保存します。

検証に失敗したときに送出する例外は、ルールの性質で使い分けます。

ルール例外理由
価格が 100 未満IllegalArgumentException引数の値が不正
タイトルの重複IllegalStateExceptionすでに登録済みという状態

検証に失敗すると、例外がそのまま画面のエラーになります。入力の誤りを画面で利用者に伝え、フォームに差し戻す仕組みは、後の章で扱います。

2-2. Controller から Service への委譲

Section titled “2-2. Controller から Service への委譲”

BookController は、ビジネスロジックを自分で持たず、BookService に依頼します。登録(create)は bookService.register を呼びます。一覧(books)も、これまで BookRepository を直接呼んでいましたが、データ操作の窓口を BookService に一本化します。

BookController.java
package com.example.bookstore;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping("/books")
public String books(Model model) {
model.addAttribute("books", bookService.findAll());
return "books";
}
@PostMapping("/books")
public String create(@ModelAttribute BookForm form) {
bookService.register(form);
return "redirect:/books";
}
}

1 節で BookController が注入していた依存は、BookRepository から BookService に変わりました。BookService も、@Repository と依存性の注入 と同じく Spring Boot が生成して注入します。登録後は リダイレクト で一覧(/books)に転送します。

BookControllerBookServiceBookRepository は、リクエストの処理・ビジネスロジック・データアクセスを 1 つずつ担います。役割ごとに層を分けたこの構成を レイヤードアーキテクチャ(層構造)と呼びます。MVC で見ると、BookController が Controller、BookServiceBookRepository が Model、テンプレート(books.html)が View にあたります。

3 つのクラスは、リクエストの入口から順に層を成します。BookControllerBookService を、BookServiceBookRepository をフィールドに持って呼び出します。逆向きの参照はありません。

BookController・BookService・BookRepository が一方向に依存する 3 つの層

Spring Boot は、この依存を BookRepository が使う DataSource までたどり、DataSource から順に生成して注入します。層が 1 つ増えても、この仕組みは変わりません。

依存が一方向なので、BookRepository のコードには呼び出し元への参照がありません。データアクセスだけを行うため、Web のコントローラーからでも、定期実行のバッチ処理や別の API からでも、同じ BookServiceBookRepository を使えます。

層を分けた効果は、変更を加えるときに表れます。検索画面を追加しても価格のルールは変わらず、ルールを厳しくしても SQL は変わりません。Service で「同じタイトルは登録しない」ルールを足したとき、修正したのは BookService(ルール)と BookRepositoryexistsByTitle)だけでした。BookControllerbooks.html も変えていません。

変更の種類によって、修正する層が決まります。

変えたいこと修正する層
取得・保存の SQL を変えるBookRepository
ビジネスロジックを変えるBookService
ルーティングや画面への受け渡しを変えるBookController
画面の見た目を変えるbooks.htmlMVC の View)

1 つの変更が 1 つの層に収まり、ほかの層に影響しません。

BookService が依存する BookRepository をインターフェイスにして実装(JdbcBookRepository)を分けると、利用側を変えずに実装を差し替えられます。Spring が Bean を生成・注入する DI コンテナー の仕組みとあわせて、付録で扱います。