第 5 章 データ永続化 ― JPA・Spring Data・R2DBC

(Spring Boot 3.5 GA・Java 17+ 前提)


5‑1 JPA クイックスタート

5‑1‑1 基本セットアップ

Spring Boot で JPA を利用するには spring-boot-starter-data-jpa を依存に追加するだけで、Hibernate・HikariCP・Transaction Manager が自動設定されます。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

5‑1‑2 エンティティの定義

package com.example.demo.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;

@Entity
@Table(name = "users")
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NotNull
    @Size(min = 2, max = 50)
    @Column(nullable = false, length = 50)
    private String username;
    
    @NotNull
    @Size(min = 1, max = 100)
    @Column(nullable = false, length = 100)
    private String email;
    
    @Column(name = "created_at")
    private LocalDateTime createdAt;
    
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }
    
    // コンストラクタ、getter、setter は省略
    
    public User() {}
    
    public User(String username, String email) {
        this.username = username;
        this.email = email;
    }
    
    // getter/setter methods...
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

5‑1‑3 リポジトリの作成

package com.example.demo.repository;

import com.example.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // クエリメソッド自動生成
    Optional<User> findByUsername(String username);
    
    List<User> findByEmailContaining(String emailPart);
    
    // カスタムクエリ
    @Query("SELECT u FROM User u WHERE u.username LIKE %:keyword% OR u.email LIKE %:keyword%")
    List<User> searchByKeyword(@Param("keyword") String keyword);
    
    // ネイティブクエリ
    @Query(value = "SELECT COUNT(*) FROM users WHERE created_at > ?1", nativeQuery = true)
    long countUsersCreatedAfter(LocalDateTime dateTime);
}

5‑1‑4 基本的な CRUD 操作

package com.example.demo.service;

import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Service
@Transactional
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    // 作成
    public User createUser(String username, String email) {
        User user = new User(username, email);
        return userRepository.save(user);
    }
    
    // 読み取り
    @Transactional(readOnly = true)
    public Optional<User> findUserById(Long id) {
        return userRepository.findById(id);
    }
    
    @Transactional(readOnly = true)
    public List<User> findAllUsers() {
        return userRepository.findAll();
    }
    
    @Transactional(readOnly = true)
    public Optional<User> findUserByUsername(String username) {
        return userRepository.findByUsername(username);
    }
    
    // 更新
    public User updateUser(Long id, String newEmail) {
        return userRepository.findById(id)
            .map(user -> {
                user.setEmail(newEmail);
                return userRepository.save(user); // Dirty Checking で自動更新
            })
            .orElseThrow(() -> new RuntimeException("User not found"));
    }
    
    // 削除
    public void deleteUser(Long id) {
        userRepository.deleteById(id);
    }
}

5‑2 JPA ベストプラクティス

5‑2‑1 ローディング戦略

遅延ローディング(Lazy Loading) をデフォルトとし、必要な場合のみ Eager Loading@EntityGraph を使用する:

@Entity
public class Order {
    @Id
    @GeneratedValue
    private Long id;
    
    // 多対一は通常 EAGER だが、パフォーマンスを考慮して LAZY に変更
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
    
    // 一対多は LAZY がデフォルト
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();
}

@EntityGraph による選択的フェッチ

public interface OrderRepository extends JpaRepository<Order, Long> {
    
    @EntityGraph(attributePaths = {"user", "items"})
    @Query("SELECT o FROM Order o WHERE o.id = :id")
    Optional<Order> findByIdWithUserAndItems(@Param("id") Long id);
}

5‑2‑2 ページングとソート

@Service
@Transactional(readOnly = true)
public class UserService {
    
    public Page<User> findUsersWithPaging(int page, int size, String sortBy) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy));
        return userRepository.findAll(pageable);
    }
    
    public Page<User> searchUsersWithPaging(String keyword, Pageable pageable) {
        return userRepository.findByUsernameContainingIgnoreCase(keyword, pageable);
    }
}

5‑2‑3 トランザクション境界の管理

@Service
public class OrderService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private InventoryService inventoryService;
    
    @Transactional // メソッドレベルでのトランザクション管理
    public Order processOrder(OrderRequest request) {
        // 1. 在庫確認・減算(別サービス)
        inventoryService.reserveItems(request.getItems());
        
        // 2. 注文作成
        Order order = new Order(request.getUserId());
        request.getItems().forEach(item -> 
            order.addItem(new OrderItem(item.getProductId(), item.getQuantity()))
        );
        
        // 3. 保存
        return orderRepository.save(order);
    }
    
    @Transactional(readOnly = true) // 読み取り専用でパフォーマンス向上
    public List<Order> findUserOrders(Long userId) {
        return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
    }
}

5‑3 R2DBC による リアクティブデータアクセス

5‑3‑1 R2DBC セットアップ

非同期・ノンブロッキングなデータアクセスを実現する R2DBC は、WebFlux との組み合わせで真価を発揮します。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
    <groupId>io.r2dbc</groupId>
    <artifactId>r2dbc-h2</artifactId>
    <scope>runtime</scope>
</dependency>

設定

spring:
  r2dbc:
    url: r2dbc:h2:mem:///testdb
    username: sa
    password: 
  sql:
    init:
      mode: always
      schema-locations: classpath:schema.sql

5‑3‑2 リアクティブエンティティとリポジトリ

package com.example.demo.entity;

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;

@Table("products")
public class Product {
    
    @Id
    private Long id;
    
    private String name;
    private Double price;
    private LocalDateTime createdAt;
    
    // constructors, getters, setters...
    public Product() {}
    
    public Product(String name, Double price) {
        this.name = name;
        this.price = price;
        this.createdAt = LocalDateTime.now();
    }
    
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    
    public Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.example.demo.repository;

import com.example.demo.entity.Product;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Repository
public interface ProductRepository extends R2dbcRepository<Product, Long> {
    
    Flux<Product> findByNameContaining(String name);
    
    Flux<Product> findByPriceBetween(Double minPrice, Double maxPrice);
    
    @Query("SELECT * FROM products WHERE price > :price ORDER BY price DESC")
    Flux<Product> findExpensiveProducts(Double price);
    
    @Query("SELECT COUNT(*) FROM products WHERE price < :maxPrice")
    Mono<Long> countAffordableProducts(Double maxPrice);
}

5‑3‑3 リアクティブサービス

package com.example.demo.service;

import com.example.demo.entity.Product;
import com.example.demo.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public class ProductService {
    
    @Autowired
    private ProductRepository productRepository;
    
    public Mono<Product> createProduct(String name, Double price) {
        Product product = new Product(name, price);
        return productRepository.save(product);
    }
    
    public Flux<Product> findAllProducts() {
        return productRepository.findAll();
    }
    
    public Mono<Product> findProductById(Long id) {
        return productRepository.findById(id);
    }
    
    public Flux<Product> searchProducts(String keyword) {
        return productRepository.findByNameContaining(keyword);
    }
    
    public Flux<Product> findProductsInPriceRange(Double minPrice, Double maxPrice) {
        return productRepository.findByPriceBetween(minPrice, maxPrice);
    }
    
    public Mono<Product> updateProduct(Long id, String newName, Double newPrice) {
        return productRepository.findById(id)
            .map(product -> {
                product.setName(newName);
                product.setPrice(newPrice);
                return product;
            })
            .flatMap(productRepository::save);
    }
    
    public Mono<Void> deleteProduct(Long id) {
        return productRepository.deleteById(id);
    }
}

5‑3‑4 WebFlux コントローラーとの統合

package com.example.demo.web;

import com.example.demo.entity.Product;
import com.example.demo.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    @Autowired
    private ProductService productService;
    
    @GetMapping
    public Flux<Product> getAllProducts() {
        return productService.findAllProducts();
    }
    
    @GetMapping("/{id}")
    public Mono<Product> getProduct(@PathVariable Long id) {
        return productService.findProductById(id);
    }
    
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<Product> createProduct(@RequestBody Product product) {
        return productService.createProduct(product.getName(), product.getPrice());
    }
    
    @PutMapping("/{id}")
    public Mono<Product> updateProduct(@PathVariable Long id, @RequestBody Product product) {
        return productService.updateProduct(id, product.getName(), product.getPrice());
    }
    
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public Mono<Void> deleteProduct(@PathVariable Long id) {
        return productService.deleteProduct(id);
    }
    
    @GetMapping("/search")
    public Flux<Product> searchProducts(@RequestParam String keyword) {
        return productService.searchProducts(keyword);
    }
}

まとめ

本章では Spring Boot 3.5 での データ永続化 の主要アプローチを解説しました:

  • JPA/Hibernate - 従来型の ORM による同期データアクセス
  • Spring Data JPA - リポジトリパターンによる定型処理の自動化
  • R2DBC - リアクティブプログラミングモデルでの非同期データアクセス

次章では、これらのデータアクセス層を活用した Web アプリケーション開発 について詳しく見ていきます。