はじめに
Spring Bootを使い、Todoを管理するREST APIを作りました。
最初はHello Spring Bootという文字列を返すだけでしたが、少しずつ機能を追加し、最終的にDockerで動かしたPostgreSQLへデータを保存できるようにしました。
この記事では、完成したコードだけでなく、途中で遭遇した404、405、500、コンパイルエラーから何を学んだかもまとめます。
今回作ったものの構成は次のとおりです。
curl・ブラウザ
↓ HTTP
Controller(HelloAPI)
↓
Service(TodoService)
↓
Repository(TodoRepository)
↓ SQL
PostgreSQL(Dockerコンテナ)
1. 最初は文字列を返すだけだった
最初に作ったAPIは、次のようなものでした。
@GetMapping("/hello")
public String hello() {
return "Hello Spring Boot";
}
Spring Bootを起動します。
./gradlew bootRun
ログに以下が出れば起動成功です。
Tomcat started on port 8080 (http)
Started DemoApplication
このとき、ターミナルとブラウザの役割を最初は混同していました。
- ターミナル:Spring Bootを動かし、ログを見る場所
- ブラウザや
curl:Spring BootへHTTPリクエストを送る側
Spring Bootは8080番ポートで待機し、/helloへのGETリクエストが来ると、対応するメソッドを実行して文字列を返します。
2. TodoをJSONで返す
次に、TodoをJSONで返すAPIを作りました。
{
"id": 1,
"title": "Spring Bootを学ぶ",
"done": false
}
JavaのオブジェクトをControllerの戻り値にすると、Spring BootがJSONへ変換してHTTPレスポンスとして返してくれます。
JSONの項目がコードに書いた順番と違うこともありましたが、JSONオブジェクトでは基本的に項目順ではなく、idやtitleなどの名前で値を判断します。
3. CRUD APIを作る
Todoに対して、次のAPIを作りました。
| HTTPメソッド | URL | 処理 |
|---|---|---|
| GET | /todos |
一覧取得 |
| GET | /todos/{id} |
1件取得 |
| POST | /todos |
新規作成 |
| PUT | /todos/{id} |
更新 |
| DELETE | /todos/{id} |
削除 |
例えば、POSTは次のように試せます。
curl -i -X POST http://localhost:8080/todos \
-H 'Content-Type: application/json' \
-d '{"title":"POSTを学ぶ","done":false}'
@RequestBodyがJSONをJavaのオブジェクトとして受け取り、@PathVariableが/todos/1のようなURLからIDを受け取ります。
@GetMapping("/todos/{id}")
public ResponseEntity<Todo> getTodo(@PathVariable long id) {
return todoService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
ここで、IDはサーバーから勝手に送られてくるものではありません。利用者がURLにIDを指定し、Spring BootがそのIDを使ってTodoを探します。
4. HTTPステータスがエラー調査の手掛かりになった
開発中に、さまざまなHTTPステータスを実際に見ました。
| ステータス | 意味 | 今回の例 |
|---|---|---|
200 OK |
処理成功 | Todoの取得・更新成功 |
204 No Content |
成功したが返す本文はない | 削除成功 |
400 Bad Request |
リクエストに問題がある | 必要なJSON項目が足りない |
404 Not Found |
対象が見つからない | 存在しないIDを取得 |
405 Method Not Allowed |
HTTPメソッドに未対応 | GETしかないURLへPOST |
500 Internal Server Error |
サーバー内部の処理失敗 | SQL実行時の問題 |
特に204は、最初は「何も返ってこないので失敗した?」と感じました。しかし実際は、「削除には成功したが、レスポンス本文は返さない」という正常な結果でした。
削除後に同じIDをGETし、404になることまで確認すると、本当に削除されたと判断できます。
5. 同じGETを二つ作ると、Spring Bootが迷う
途中でControllerファイルを二つ作り、その両方に同じマッピングを書いて起動エラーになりました。
GET /todos → メソッドA
GET /todos → メソッドB
問題は「ファイルが二つあること」ではありません。実際の開発では、機能ごとにControllerを分割します。
問題なのは、URLとHTTPメソッドの組み合わせが重複し、Spring Bootがどちらを呼ぶか決められないことです。
6. 最初はメモリ上へ保存した
初期段階では、TodoをArrayListへ保存していました。
この方法は仕組みを学ぶには分かりやすい一方、Spring Bootを停止すると追加したデータが消えます。
Spring Bootを起動 → メモリ上にデータがある
Spring Bootを停止 → メモリ上のデータが消える
そこで、保存先をPostgreSQLへ変更しました。
7. DockerでPostgreSQLを動かす
Docker Composeを使い、PostgreSQLコンテナを起動しました。
docker compose up -d
docker compose ps
ここで理解した大事な点は、Docker自体がDBなのではないことです。
Dockerはアプリを隔離した環境で動かす仕組みで、そのコンテナの中でPostgreSQLが動いています。
Mac
├── Spring Boot
└── Docker
└── PostgreSQLコンテナ
└── tododb
Spring Boot側には、PostgreSQLへ接続する設定を追加しました。
spring.datasource.url=jdbc:postgresql://localhost:5432/tododb
spring.datasource.username=${DB_USERNAME:todo_user}
spring.datasource.password=${DB_PASSWORD:todo_password}
spring.sql.init.mode=always
起動ログでは、次の行が接続成功の手掛かりになりました。
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection
HikariPool-1 - Start completed
8. RepositoryからSQLを実行する
DB操作はTodoRepositoryへまとめ、JdbcTemplateを使ってSQLを実行しました。
@Repository
public class TodoRepository {
private final JdbcTemplate jdbcTemplate;
public TodoRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
}
一覧取得では、SQLの各行をTodoへ変換します。
public List<Todo> findAll() {
return jdbcTemplate.query(
"SELECT id, title, done FROM todos ORDER BY id",
(rs, rowNum) -> new Todo(
rs.getLong("id"),
rs.getString("title"),
rs.getBoolean("done")
)
);
}
削除では、SQLによって削除された行数を見て成功・失敗を判断します。
public boolean deleteById(long id) {
int deleted = jdbcTemplate.update(
"DELETE FROM todos WHERE id = ?",
id
);
return deleted > 0;
}
APIのレスポンスだけでなく、PostgreSQLへ直接問い合わせることで、データが本当にDBへ保存されたか確認しました。
docker compose exec db psql -U todo_user -d tododb \
-c "SELECT id, title, done FROM todos;"
9. コンパイルエラーをどこから読むか
削除処理を追加したとき、次のコンパイルエラーが発生しました。
HelloAPI.java:144: エラー: シンボルを見つけられません
boolean deleted = todoRepository.deleteById(id);
^
シンボル: メソッド deleteById(long)
場所: タイプTodoRepositoryの変数 todoRepository
このログは次の順番で読むと、原因を絞り込めます。
どのファイル? → HelloAPI.java
何行目? → 144行目
何がない? → deleteById(long)
どこにない? → TodoRepository
原因は、ControllerがdeleteById(id)を呼んでいるのに、保存済みのTodoRepository.javaにはそのメソッドがなかったことでした。
ログ末尾のBUILD FAILEDは失敗したという結果です。本当の原因を知るには、その少し上にあるファイル名、行番号、エラー内容を読む必要があります。
10. Service層を追加する
PostgreSQL版CRUDが動いたあと、TodoServiceを追加しました。
変更前は、ControllerがRepositoryを直接呼んでいました。
HelloAPI → TodoRepository → PostgreSQL
変更後は、Serviceを間に置きました。
HelloAPI → TodoService → TodoRepository → PostgreSQL
@Service
public class TodoService {
private final TodoRepository todoRepository;
public TodoService(TodoRepository todoRepository) {
this.todoRepository = todoRepository;
}
public List<Todo> findAll() {
return todoRepository.findAll();
}
}
現時点のServiceはRepositoryを呼ぶだけなので、利用者から見える結果は変わりません。
しかし、今後「タイトルを空にできない」「完了済みTodoには特別な処理をする」といったアプリ固有のルールを、HTTP処理やSQLから分離して書けるようになりました。
今日やったこと
2026年9月6日に行った作業です。
- DockerでPostgreSQLを起動
- Spring BootとPostgreSQLの接続を確認
- POSTでTodoをDBへ保存
- SQLを直接実行して保存結果を確認
- PUTでDB上のTodoを更新
- DELETEでDB上のTodoを削除
-
deleteById(long)が見つからないコンパイルエラーを調査・修正 -
TodoServiceを新規作成 - ControllerからServiceを呼ぶ構成へ変更
-
GET /todosが200 OKになることを確認 - 変更をGitへコミット
学んだこと
- HTTPリクエストがController、Service、Repositoryを通ってDBへ届く流れ
- DockerとPostgreSQLは同じものではないこと
-
JdbcTemplateがJavaからSQLを実行する窓口であること - APIの結果とSQLの両方から、DBの状態を確認できること
- Controller、Service、Repositoryで担当を分ける理由
- HTTPステータスから、どこまで処理できたか推測できること
- コンパイルエラーでは、ファイル名・行番号・見つからないもの・探した場所を読むこと
- 過去のログと最新ログが混ざった場合は、時刻と末尾の結果を見ること
おわりに
今回の学習では、コードを動かすことだけでなく、次の点を言葉で説明できるようになってきました。
データはどこから来たのか
どのクラスを通ったのか
どこに保存されたのか
成功・失敗をどの表示から判断したのか
次はService層に入力チェックを追加し、タイトルが空の場合に400 Bad Requestを返す処理へ進みます。その後、自動テスト、古い学習用コードの整理、README更新まで行う予定です。