はじめに
前回([ローカル環境構築編])で、Mac M1 Proに環境を作って @RestController でHello Worldを返すところまでやりました。
次回は Spring Data JPAとPostgreSQLでDB連携 に進んでみます。
…と書いたので、有言実行です。
正直、ここからが本番という気がしています。
APIがメモリ上の文字列を返すだけなら大したことはないけれど、DBと繋がって初めて「アプリ」っぽくなるわけで。
PythonではSQLAlchemyやSQLModelでDBを触ってきたので、「ORMの概念」自体は分かっているつもり。
今回はその知識を踏まえつつ、Spring Data JPAがどれくらい楽をさせてくれるのかを確かめていきます。
ついでに前回「Lombokは次回以降で試す」と書いたので、今回しっかり使います。
対象読者
- 前回の[ローカル環境構築編]を読んだ(or 同等の環境がある)
- Java(Spring含む)は触ったことがある
- PythonのORM(SQLAlchemy / SQLModelなど)を触ったことがある
- Spring Data JPAはほぼ未経験
- Mac M1/M2 Pro使用中
この記事のゴール
Task(タスク)テーブルに対して、**一覧取得・登録・更新・削除(CRUD)**ができるREST APIを作ります。
GET /tasks → 一覧取得
POST /tasks → 登録
GET /tasks/{id} → 1件取得
PUT /tasks/{id} → 更新
DELETE /tasks/{id} → 削除
DBはPostgreSQL。Macに直接入れず、Dockerで立てる方針にします(M1でも素直に動くし、消すのも楽なので)。
環境
| 項目 | バージョン |
|---|---|
| macOS | Sonoma 14.x(M1 Pro) |
| Java | 21.0.7-tem(Temurin) |
| Spring Boot | 4.0.6 |
| ビルドツール | Maven |
| DB | PostgreSQL 17(Docker) |
| ORM | Spring Data JPA(Hibernate 7系) |
補足:Spring Boot 4.x はJava 17以上が必須です。前回同様、広く使われている安定LTSのJava 21を使っています。バージョンは時期によって上がるので、
sdk list javaで最新パッチを確認してから入れてください。
1. PostgreSQLをDockerで立てる
まずはDBがないと話が始まりません。プロジェクト直下に docker-compose.yml を置きます。
# docker-compose.yml
services:
db:
image: postgres:17
platform: linux/arm64 # M1の場合はこれを明示しておくと無駄なエミュレーションを避けられる
container_name: springboot-postgres
environment:
POSTGRES_DB: demo
POSTGRES_USER: demo_user
POSTGRES_PASSWORD: demo_pass
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
起動します。
docker compose up -d
接続確認(任意)。
docker exec -it springboot-postgres psql -U demo_user -d demo
demo=# \l
demo=# \q
Pythonでいうと
docker compose up -dでDBを使い捨てにする感覚は、Python開発でもよくやるやつですね。ここはほぼ同じです。
2. 依存関係を追加する
前回作ったプロジェクトの pom.xml に、JPAとPostgreSQLドライバを追加します。
<!-- pom.xml の <dependencies> 内に追加 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Pythonでいうと
spring-boot-starter-data-jpaがpip install sqlalchemy相当、postgresqlドライバがpip install psycopg2-binary相当のイメージです。
違うのは、Springの「スターター」はJPA本体・Hibernate・コネクションプール(HikariCP)・トランザクション管理あたりをまとめて引き込んでくれる点。ここはMavenの便利なところです。
追加したら依存を反映します(IDEがやってくれることも多いですが、CLIなら)。
./mvnw clean compile
3. DB接続設定を書く
src/main/resources/application.properties に接続情報を書きます。
(YAML派の人向けに application.yml 版も後述します)
# src/main/resources/application.properties
# --- DB接続 ---
spring.datasource.url=jdbc:postgresql://localhost:5432/demo
spring.datasource.username=demo_user
spring.datasource.password=demo_pass
# --- JPA / Hibernate ---
# 開発中はupdateで自動生成。本番ではvalidate or noneにすること!
spring.jpa.hibernate.ddl-auto=update
# 実行されるSQLをログに出す(学習中は見えた方が理解が早い)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
YAML派はこちら。
# src/main/resources/application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/demo
username: demo_user
password: demo_pass
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
ddl-autoは要注意ポイント
updateはEntityの定義に合わせてテーブルを勝手に作ってくれる便利モードですが、本番でやると事故ります。
学習・開発中はupdate、本番はvalidate(ornone)+マイグレーションツール(Flyway / Liquibase)が定石、と覚えておけば十分です。
※パスワードを直書きしているのはあくまで学習用です。実際は環境変数やapplication-local.yml等に逃がしましょう。
4. Entity(テーブルに対応するクラス)を作る
ここで前回お預けにしていたLombokが活きます。
src/main/java/com/example/demo/task/Task.java
package com.example.demo.task;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "tasks")
@Getter
@Setter
@NoArgsConstructor
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private boolean done;
}
ポイントを整理します。
| アノテーション | 役割 |
|---|---|
@Entity |
このクラスをDBテーブルに対応させる |
@Table(name="tasks") |
テーブル名を明示(省略するとクラス名から推測) |
@Id |
主キー |
@GeneratedValue(IDENTITY) |
PostgreSQLの自動採番(連番)に任せる |
@Getter / @Setter / @NoArgsConstructor |
Lombokがgetter/setter/コンストラクタを自動生成 |
Pythonでいうと
SQLAlchemyの宣言的マッピングそっくりです。class Task(Base): __tablename__ = "tasks" id = Column(Integer, primary_key=True) title = Column(String) done = Column(Boolean, default=False)
@EntityがBase継承、@Idがprimary_key=Trueに相当します。
ただしJavaは型が明示的な分、本来getter/setterで縦に長くなりがち。そこを Lombokが丸ごと潰してくれるので、SQLAlchemyに見劣りしないくらいスッキリします。前回「冗長に見える」と書いた懸念は、Lombokでかなり解消されました。
地味だけど大事な注意
Spring Boot 3以降、importはjavax.persistence.*ではなくjakarta.persistence.*です。ネットの古い記事をコピペするとjavaxで詰まるので注意。
5. Repository(DBアクセス層)を作る
ここがSpring Data JPAの一番おいしいところです。
src/main/java/com/example/demo/task/TaskRepository.java
package com.example.demo.task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
// ここに何も書かなくても、CRUDメソッドが揃っている
}
これだけ。
interface を1つ宣言するだけで、findAll() / findById() / save() / deleteById() などが実装ゼロで使えます。実体のクラスは書きません。Springが起動時に勝手に実装を生成して注入してくれます。
Pythonとの一番大きな差はここ
SQLAlchemyだと、CRUDのロジック(session.add()、session.commit()、session.query(...).all()…)は基本的に自分で書きますよね。
Spring Data JPAは、よくあるCRUDを「規約」で吸収してくれるので、定型コードがほぼ消えます。最初は「え、本当に動くの?」と疑うレベルで楽でした。
しかも、メソッド名から自動でクエリを組み立てる仕組みもあります。
public interface TaskRepository extends JpaRepository<Task, Long> {
// SELECT * FROM tasks WHERE done = ? が自動生成される
List<Task> findByDone(boolean done);
// タイトル部分一致
List<Task> findByTitleContaining(String keyword);
}
メソッド名を書くだけでSQLが組まれる、いわゆるクエリメソッドです。慣れると相当強力です。
6. Service(業務ロジック層)を作る
ControllerからRepositoryを直接呼んでもいいのですが、層を分けておくのがSpringの作法です。
src/main/java/com/example/demo/task/TaskService.java
package com.example.demo.task;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class TaskService {
private final TaskRepository repository;
// コンストラクタインジェクション(Springが自動でrepositoryを渡してくれる)
public TaskService(TaskRepository repository) {
this.repository = repository;
}
public List<Task> findAll() {
return repository.findAll();
}
public Task findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Task not found: " + id));
}
public Task create(Task task) {
return repository.save(task);
}
public Task update(Long id, Task input) {
Task task = findById(id);
task.setTitle(input.getTitle());
task.setDone(input.isDone());
return repository.save(task); // 既存IDがあればUPDATEになる
}
public void delete(Long id) {
repository.deleteById(id);
}
}
Pythonでいうと
FastAPIで「routerにロジックを全部書かず、サービス関数に切り出す」あの分け方と同じ発想です。
finalフィールド+コンストラクタ注入は、FastAPIのDepends()でDBセッションを受け取るのと役割が近いです(DIの思想は共通)。
7. Controller(REST API層)を作る
前回 @RestController でHello Worldを返した、あのControllerの本格版です。
src/main/java/com/example/demo/task/TaskController.java
package com.example.demo.task;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/tasks")
public class TaskController {
private final TaskService service;
public TaskController(TaskService service) {
this.service = service;
}
@GetMapping
public List<Task> list() {
return service.findAll();
}
@GetMapping("/{id}")
public Task get(@PathVariable Long id) {
return service.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Task create(@RequestBody Task task) {
return service.create(task);
}
@PutMapping("/{id}")
public Task update(@PathVariable Long id, @RequestBody Task task) {
return service.update(id, task);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
service.delete(id);
}
}
Pythonでいうと
@app.get("/tasks") def list_tasks(): ... @app.post("/tasks", status_code=201) def create_task(task: TaskIn): ...
@RequestBodyが FastAPIの「引数にPydanticモデルを取る」部分、@PathVariableが/{id}のパスパラメータに相当します。アノテーションの思想は前回同様ほぼ一致していて、ここはすんなり入れました。
8. 動かして確認する
DB(Docker)が起動している状態で、アプリを起動します。
./mvnw spring-boot:run
起動ログに、Hibernateが tasks テーブルを作るSQL(create table tasks ...)が出ていれば成功です(ddl-auto=update の効果)。
別ターミナルから叩いてみます。
# 登録
curl -X POST http://localhost:8080/tasks \
-H "Content-Type: application/json" \
-d '{"title": "JPAを学ぶ", "done": false}'
{"id":1,"title":"JPAを学ぶ","done":false}
# 一覧取得
curl http://localhost:8080/tasks
[{"id":1,"title":"JPAを学ぶ","done":false}]
# 更新(完了にする)
curl -X PUT http://localhost:8080/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title": "JPAを学ぶ", "done": true}'
# 削除
curl -X DELETE http://localhost:8080/tasks/1
コンソールに実際のSQL(insert into tasks ... / select ... from tasks など)が流れているはずです。show-sql=true にしておくと、ORMが裏で何をしているかが見えて勉強になります。
JPAが裏で何をしているか(ざっくり)
「repository.save() を呼んだだけでなぜSQLが走るのか」を一度押さえておくと、後でハマりません。
まず、リクエストが通る「層」の全体像です。
自分で書くのは Controller / Service / Repository の3つだけ。
Repository の中身(実装)と、その先の Hibernate ⇔ DB のやり取りは、Springがまるごと面倒を見てくれるのが分かります。
次に、POST /tasks(登録)を1本叩いたときに裏で何が起きているかを時系列で。
ポイントは save() ひとつで INSERTにもUPDATEにもなるところ。idの有無でHibernateが判断しています。
@Entity のオブジェクトと、テーブルの「行」を相互変換しているのがHibernate(JPA実装)です。
SQLAlchemyでいう「セッション」「フラッシュ」「マッピング」を、Springがかなりの部分自動で面倒見てくれている、という理解でだいたい合っています。
M1 Pro特有の注意点
前回も触れましたが、DBが絡むと改めて気にする箇所が出ます。
-
PostgreSQLイメージ:
postgresの公式イメージはARM64対応ビルドがあるので、M1でもネイティブで動きます。platform: linux/arm64を明示しておくと、意図しないエミュレーション起動を避けられます。 -
ポート競合:Macに別途PostgreSQLを入れている人は
5432が衝突しがち。その場合は"15432:5432"のようにホスト側ポートをずらし、接続URLも合わせて変更してください。 -
ボリュームの後始末:データを完全に消してやり直したいときは
docker compose down -v(-vでボリュームごと削除)。学習中は気軽にリセットできて便利です。
ハマりポイントメモ
実際に詰まった/詰まりそうな箇所をメモしておきます。
| 症状 | 原因 / 対処 |
|---|---|
Cannot resolve jakarta.persistence |
javax でimportしている。Spring Boot 3以降は jakarta.*
|
起動時に Connection refused
|
DockerのDBが起動しきっていない。docker compose ps で確認 |
| テーブルが作られない |
ddl-auto が none になっている。学習中は update
|
| getter/setterが見つからないとビルドエラー | IDEのLombokプラグイン未導入 or アノテーションプロセッサ未有効 |
| 起動はするがSQLが見えない |
spring.jpa.show-sql=true を入れる |
まとめ
| やったこと | 内容 |
|---|---|
| DB準備 | PostgreSQLをDockerで起動(M1向けに linux/arm64 指定) |
| 依存追加 |
spring-boot-starter-data-jpa + PostgreSQLドライバ |
| 接続設定 |
application.properties でDB接続&ddl-auto
|
| Entity |
@Entity + Lombokでテーブル対応クラスを定義 |
| Repository |
JpaRepository 継承だけでCRUDが揃う |
| Service / Controller | 層を分けてREST APIでCRUDを公開 |
一番の感想は、**「Repositoryのinterface書くだけでCRUD全部揃うの、ズルくない?」**でした。
SQLAlchemyだと自分で書いていた定型処理が、Spring Data JPAでは規約に吸収されてごっそり消える。ここはPython経験者ほど「楽になった感」が分かりやすいと思います。
一方で、ddl-auto の挙動やトランザクション境界など、「自動でやってくれる部分の中身」を知らないと本番でハマるな、というのも見えてきました。便利さと引き換えに、裏側の理解は別途必要そうです。
次回予告
CRUDはできたものの、いまのコードはバリデーションも例外ハンドリングもガバガバです。
title が空でも登録できるし、存在しないIDを叩くと500が返ります。
次回は Bean Validationでの入力チェック + 例外ハンドリング(@ControllerAdvice) を入れて、もう少し「ちゃんとしたAPI」に寄せていきます。
参考
この記事はシリーズの第2回です。
【前回】ローカル環境構築編 / 【次回】Bean Validation × 例外ハンドリング編

