Spring BootでREST APIを作りたいのに、情報が多すぎて「え、結局どこから?」ってなってませんか?わかります。そこでこの記事は、最短ルートで“APIを1本動かすことだけに全集中します。
今回のゴールはこれです👇
TODO APIを3本(追加・一覧・削除)作って、curlかPostmanで動くのを確認する。
難しい設計の話はあとでOKです。まずは「動いた!」を体験しましょう。
まず完成の姿(これが動けば勝ちです)
そして確認は以下をコピペすることで実施できます👇
# 追加
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"牛乳を買う"}'
# 一覧
curl http://localhost:8080/api/todos
# 削除(idは例)
curl -X DELETE http://localhost:8080/api/todos/1
読み終わるとできること
「ちゃんと理解してから…」も大事ですが、最初はそれで止まりがちです。なのでこの記事は、理解はあとから追いつく前提でOKにしてあります。

それじゃ、まずはREST APIのルールを「3つだけ」覚えるところからいきます。
REST APIは「3つだけ」覚えればOK
RESTって聞くと急にむずかしそうですが、最初はガチで 3つだけ 覚えれば大丈夫です。
それは
- やること(GET/POST/DELETE)
- URLの形
- 返す番号(ステータスコード)
の3点セットです。
ここを押さえると、RESTのモヤが一気に晴れます。
GET/POST/DELETEを日本語にすると?
まずこれを固定しちゃいましょう。APIはだいたいこの3つで回ります。
今回のTODOなら、
見る=GET(一覧)/増やす=POST(追加)/消す=DELETE(削除) でOKです。
いきなりPUTとかPATCHとか出てきても、今日はスルーで大丈夫です。まず動かすのが最優先です。
URLの形は「名詞」でそろえる(/api/todos)
次はURLです。ここでやりがちなミスが「動詞をURLに入れる」パターンです。
コツはシンプルで、URLは“モノの名前(名詞)”にするだけです。
そして「何をするか」はURLじゃなくて、GET/POST/DELETE が決めてくれます。

ステータスコードはまず3つ(200/400/404)
最後は「返す番号」です。いっぱいありますが、最初はこの3つで十分です。
例えばTODO削除で、存在しないIDを消そうとしたら 404 が自然です。
逆に、JSONが壊れてる・必須項目が空みたいな「入力がおかしい」は 400 です。
こんな感じで、RESTは「3つの型」を先に決めると迷子になりません。

次は、その型を実際のゴールに当てはめます。今回作るTODO APIの仕様を先にガチッと固定しちゃいましょう。
まずはゴールを1つに絞る:TODO APIの仕様
ここで大事なのは「何を作るか」を先に固定しちゃうことです。
途中で「機能増やしたい…」「DBも入れたい…」ってなりがちなんですが、それやるとほぼ確実に迷子になります。
今回は最短で動かす回なので、TODO APIは最小セットの3本だけにします。
今回作るエンドポイント一覧(最小セット)
| メソッド | URL | やること | 返すもの(ざっくり) |
|---|---|---|---|
| POST | /api/todos | TODOを1件追加 | 追加されたTODO(id付き) |
| GET | /api/todos | TODO一覧を取得 | TODO配列 |
| DELETE | /api/todos/{id} | 指定IDのTODOを削除 | 何も返さない(or メッセージ) |
ポイントはこれです👇
URLは名詞で固定して、やることはメソッドで決める。(H2-1の復習ですね)
リクエストJSONとレスポンスJSONの完成見本
先に「完成形」を見せます。これが返ってくれば勝ちです。
追加(POST /api/todos)
リクエストJSON(送るやつ)
{
"title": "牛乳を買う"
}
レスポンスJSON(返ってくる理想形)
{
"id": 1,
"title": "牛乳を買う"
}
一覧(GET /api/todos)
レスポンスJSON(配列で返す)
[
{ "id": 1, "title": "牛乳を買う" },
{ "id": 2, "title": "郵便局に行く" }
]
削除(DELETE /api/todos/1)
削除は「返すもの」に迷いやすいんですが、最短ならこれでOKです。
例:

ここまでで「作るもの」が完全に固まりました。
次は、Spring Bootで迷わないための超重要パートです。
Controller / Service / Repository / DTO / Entity の役割をテンプレで固定して、コードがゴチャつくのを防ぎます。
役割分担テンプレ:Controller/Service/Repository/DTO/Entity
Spring BootでREST APIを作るとき、いちばん詰まりやすいのがここです。
「どこに何を書けばいいんでしたっけ…?」ってなって、Controllerが巨大化して爆発します。なので最初から、役割をテンプレで固定しちゃいます。
結論だけ先に言いますね。
Controller=受付、Service=ルール、Repository=保管
たとえ話でいきます。TODOアプリを「お店」だと思ってください。
これで「置き場所」が決まるので、迷いが減ります。
やってはいけない例(これが沼の入口です)
- ❌ Controllerに全部書く(保存もルールも例外処理も全部)
→ 最初は動くんですが、すぐ読めなくなります。
“動かすために雑に書く”のと、“置き場所を決めずに書く”のは別問題なので、役割分担だけは最初からやっておくのがコスパ最強です。
DTOとEntityは分ける(結論:最初からDTO推し)
ここも迷うポイントですが、結論はこれです。
最初からDTOで返してください。(強めに言い切ります)
理由はシンプルで、DTOを分けておくと後で困らないからです。
イメージはこんな感じです👇
Entityは本来DBとセットで出てくるので、今回は「将来こういうのがあるよ」くらいでOKです。
最初はDBなし(メモリ保管)でOK
「Repositoryって言うならDBいる?」って思いますよね。いりません。最初はメモリで十分です。
あとからDBに変えるときも、Repositoryに閉じ込めておけば差し替えやすいです。
つまり、いまメモリで作るのは“手抜き”じゃなくて“順番”です。

ここまでで「役割」と「置き場所」が決まりました。
次はいよいよ手を動かします。Spring Bootで最短構成のプロジェクトを作って、コピペで動くコードまで一気にいきます。
実装ステップ:Spring Bootで最短構成を作る
ここは「手を動かして、動いた!」まで一直線でいきます。
0円でも開始可能です(こだわる人は有料IDEもアリ)。
Spring Bootは今だと4系も出てますが、最低でもJava 17が必要です。
道具を用意(無料でOK)

プロジェクト作成(依存関係は最小)
Spring Initializrで Dependenciesは「Spring Web」だけにします。
作れたら起動して、ログに Started ... が出たら成功のサインです。
パッケージ構成(迷わない置き場所)
com.example.todo 配下にこれだけ作ります。
controller/service/repository/dto
コピペで動くコード(Controller/Service/Repository)
dto/CreateTodoRequest.java
package com.example.todo.dto;
public record CreateTodoRequest(String title) {}
dto/TodoResponse.java
package com.example.todo.dto;
public record TodoResponse(long id, String title) {}
repository/TodoRepository.java
package com.example.todo.repository;
import com.example.todo.dto.TodoResponse;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Repository
public class TodoRepository {
private final Map<Long, String> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong(0);
public TodoResponse save(String title) {
long id = seq.incrementAndGet();
store.put(id, title);
return new TodoResponse(id, title);
}
public List<TodoResponse> findAll() {
return store.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(e -> new TodoResponse(e.getKey(), e.getValue()))
.toList();
}
public boolean delete(long id) {
return store.remove(id) != null;
}
}
service/TodoService.java
package com.example.todo.service;
import com.example.todo.dto.TodoResponse;
import com.example.todo.repository.TodoRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TodoService {
private final TodoRepository repo;
public TodoService(TodoRepository repo) { this.repo = repo; }
public TodoResponse create(String title) { return repo.save(title); }
public List<TodoResponse> list() { return repo.findAll(); }
public void delete(long id) {
if (!repo.delete(id)) throw new RuntimeException("Todo not found");
}
}
controller/TodoController.java
package com.example.todo.controller;
import com.example.todo.dto.CreateTodoRequest;
import com.example.todo.dto.TodoResponse;
import com.example.todo.service.TodoService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/todos")
public class TodoController {
private final TodoService service;
public TodoController(TodoService service) { this.service = service; }
@PostMapping
public ResponseEntity<TodoResponse> create(@RequestBody CreateTodoRequest req) {
return ResponseEntity.ok(service.create(req.title()));
}
@GetMapping
public List<TodoResponse> list() { return service.list(); }
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}

ここまでで起動して、ログに Started ... が出たらOKです。次は「JSONの受け取り・返し方」を、@RequestBodyとResponseEntityで気持ちよく整えます。
JSONの受け取りと返し方:RequestBodyとResponseEntity
REST APIでいちばん不安になりやすいのが「JSONってどう受け取って、どう返すの?」問題です。
ここはコツがあります。
“受け取る箱”と“返す箱”をDTOで固定して、Controllerはそれを使うだけにすると一気にラクになります。
POSTで受け取る(@RequestBodyの付け忘れ注意)
POSTでJSONを受け取るときは、Controllerの引数に @RequestBody を付けます。これ、忘れると「titleがnull」「400になる」みたいな事故が起きやすいです。
@PostMapping
public ResponseEntity<TodoResponse> create(@RequestBody CreateTodoRequest req) {
var created = service.create(req.title());
return ResponseEntity.ok(created);
}
「JSONをJavaの箱(DTO)に入れてね」の合図が @RequestBody だと思ってください。
返すときは「箱」をそろえる(DTO)
受け取り用DTOと、返す用DTOは分けちゃいましょう。
こうすると、あとで項目が増えても「外に出す形(レスポンス)が勝手に変わる」事故を防げます。
APIは外との約束なので、返す箱は特にDTOで固定が安心です。
201/204も使うと気持ちいい(でも必須じゃない)
ステータスコードは、最短でいくなら 200で統一でもOKです。
ただ、少しだけ“それっぽく”するならこの2つが気持ちいいです。
例:POSTを201にするならこんな感じです👇
@PostMapping
public ResponseEntity<TodoResponse> create(@RequestBody CreateTodoRequest req) {
var created = service.create(req.title());
return ResponseEntity.status(201).body(created);
}

「最初は動けばOK、慣れたら201/204で整える」で大丈夫です。次は、失敗したとき(404/400/500)を毎回同じ形のエラーJSONで返すテンプレにして、さらに安心していきます。
エラー時にどう返す?404/400/500を「型」で固定する
APIって、成功より失敗の返し方で「ちゃんとしてる感」が出ます。
ここは迷わずテンプレ化しちゃいましょう。
コツは1つだけで、エラーのJSONの形(型)を固定します。
返すエラーJSONを決める(message/code)
まずはエラーの完成形をこれに決めます👇
{
"message": "Todo not found",
"code": "TODO_NOT_FOUND"
}
DTO(箱)も用意します。
// dto/ErrorResponse.java
package com.example.todo.dto;
public record ErrorResponse(String message, String code) {}
@ControllerAdviceでまとめて返す流れ
次に、例外(エラー)を全部ここに集めて返す係を作ります。
Controllerに毎回try-catchを書くのはしんどいので、まとめて一発でいきます。
// exception/NotFoundException.java
package com.example.todo.exception;
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) { super(message); }
}
// exception/BadRequestException.java
package com.example.todo.exception;
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) { super(message); }
}
// exception/GlobalExceptionHandler.java
package com.example.todo.exception;
import com.example.todo.dto.ErrorResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException e) {
return ResponseEntity.status(404).body(new ErrorResponse(e.getMessage(), "TODO_NOT_FOUND"));
}
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(BadRequestException e) {
return ResponseEntity.status(400).body(new ErrorResponse(e.getMessage(), "BAD_REQUEST"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleOther(Exception e) {
return ResponseEntity.status(500).body(new ErrorResponse("Unexpected error", "INTERNAL_ERROR"));
}
}

よくある失敗→どう直す?(例:IDがない)
失敗例:DELETEで存在しないIDを消したのに、なぜか500になる
これ、だいたい「見つからないのに例外を投げてない」か「RuntimeExceptionのまま投げてる」パターンです。
直し方はこれです👇
例:Service側(削除)をこうします。
public void delete(long id) {
if (!repo.delete(id)) throw new NotFoundException("Todo not found");
}
これで、404/400/500が毎回同じ形で返るので、動作確認もデバッグもめちゃ楽になります。
次はその動作確認を、curlとPostmanのコピペで一気に終わらせます。
動作確認:curlとPostmanは「これだけ」でOK
ここまで来たら、あとは「本当に動いてる?」を確認するだけです。
動作確認は凝らなくてOKで、curlはコピペ3本、PostmanはURL入れてSendで終わります。
つまずいても、見る順番さえ決めておけば復帰できます。
curlコピペ集(追加→一覧→削除)
まずアプリを起動して、http://localhost:8080 で動いてる前提です。
追加(POST)
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"牛乳を買う"}'
成功すると、だいたいこんなJSONが返ります👇(idは環境で変わります)
{ "id": 1, "title": "牛乳を買う" }
一覧(GET)
curl http://localhost:8080/api/todos
こんな感じで配列が返ればOKです👇
[
{ "id": 1, "title": "牛乳を買う" }
]
削除(DELETE)
curl -X DELETE http://localhost:8080/api/todos/1
成功なら 204(ボディなし)になる想定でしたね。
もし存在しないIDなら、H2-6で決めたエラーJSONで 404 が返るはずです👇
{ "message": "Todo not found", "code": "TODO_NOT_FOUND" }
Postmanは「URL入れてSend」だけ
Postmanは難しく考えなくてOKで、最短はこれです。
- New(または+)でリクエスト作成
- 上の欄に URLを貼る(例:
http://localhost:8080/api/todos) - POSTのときだけ Body → raw → JSON を選んで、これを貼って Send
{ "title": "郵便局に行く" }
GETはURL貼ってSend、DELETEはメソッドをDELETEにしてSend。以上です。

つまずきチェックリスト(ポート/JSON/ログ)
動かないときは、順番をこれに固定してください。これだけで復帰率が上がります。
- まずログ
- 起動時に
Started ...が出てる? - エラーが出てたら、その行の周りに答えがあります
- 起動時に
- 次にURL
localhost:8080になってる?(8081とかにズレてない?)/api/todosの s 抜けてない?(地味に多いです)
- 最後にJSON
Content-Type: application/jsonを付けた?(curlのPOST)- JSONのカッコ
{}やダブルクォート"崩れてない?

ここまでで「作る→動かす→確認する」まで通りました。
次は、フロントとつなぐときに出てくるラスボスっぽいやつ、CORSを「必要なときだけ最小設定」で片付けて、最後に次の一歩を3つだけ提示して締めます。
CORSと次の一歩:必要なときだけ最小で設定する
CORSが要るのは「フロントが別住所」のとき
CORSが必要になるのは、ざっくり言うと フロント(ブラウザ)が別の住所からAPIを呼ぶときです。
例:フロントが http://localhost:3000、APIが http://localhost:8080 みたいに ポートが違うだけでも「別住所」扱いになります。逆に、同じ住所なら気にしなくてOKです。
まずは許可を小さく(特定のURLだけ)
最初から「全部OK」はおすすめしません。許可する相手(Origin)と、許可するURL(Path)を絞るのが安全です。
// config/CorsConfig.java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/todos/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET","POST","DELETE");
}
}
これで「TODOだけ」「このフロントだけ」許可、という最小構成になります。
次にやると伸びる3つ(DB/H2・入力チェック・API一覧)
動いたら、次はこの3つだけやると一気にレベルアップします。
- DBを入れる(学習用ならH2):無料。メモリ卒業して「保存できるAPI」になります
- 入力チェック:
titleが空のときは400、みたいな“お作法”が整います - API一覧を出す(Swagger UI系):無料〜有料。チーム開発だと特に便利です
※道具カテゴリと価格帯目安

これで「最短で1本動かす」はクリアです。
あとは、同じ型でエンドポイントを増やしていけば、自然に“API開発の筋力”がついてきますよ。
よくある質問
- QSpring Bootって、何から入れればいいですか?
- A
Spring Initializrで「Spring Web」だけ入れて作るのが最短です。最初はDBもセキュリティも無しでOKです。まずAPIを1本動かすのが勝ちです。
- QJavaは何のバージョンが必要ですか?
- A
だいたいの環境で安全なのは Java 17以上です。プロジェクトが起動しないときは、まずここを疑ってください。
- QREST APIって結局なにを守ればいいですか?
- A
最初はこれだけでOKです。
- QURLに
/getTodosみたいに動詞を入れちゃダメですか? - A
ダメではないですが、RESTっぽくないので迷いの原因になります。
URLは/api/todosみたいに名詞で統一して、何をするかは GET/POST/DELETE に任せるのがラクです。
- Q
@RequestBodyを付けないとどうなりますか? - A
POSTでJSONを送っても DTOが空(null)になったり、400になったりします。
「JSONをこの引数に入れてね」の合図が@RequestBodyなので、付け忘れ注意です。
- QDTOとEntityって、最初から分けないとダメですか?
- A
最短で動かすだけなら分けなくても動きます。でもおすすめは 最初からDTOで返すです。
理由は、あとで項目が増えたときに「外に出しちゃいけないデータ」まで返す事故を防げるからです。
- QDELETEの成功って、200と204どっちが正解ですか?
- A
どっちでもOKです。
この記事の流れだと、DELETEは 204 が気持ちいい寄りです。
- Q存在しないIDをDELETEしたら、何を返すのが自然ですか?
- A
404 Not Found が自然です。
そしてエラーのJSONは毎回同じ形(例:messageとcode)で返すと、フロントもデバッグも楽になります。
- Q500が返ってきます。どこを見ればいいですか?
- A
見る順番はこれで固定すると復帰しやすいです。
- ログ(例外の原因がほぼ書いてある)
- URL(パス・ポート・
/api/todosのtypo) - JSON(カッコ、ダブルクォート、Content-Type)
- QCORSエラーが出ました。必ず設定が必要ですか?
- A
ブラウザから、別の住所(別ドメイン/別ポート)で呼ぶときだけ必要です。
設定するなら「全部許可」じゃなくて、/api/todos/**だけ+特定のOriginだけみたいに小さく許可するのが安全です。
