Spring Bootで「REST APIを作ろう」と思ったとき、Controller・Service・Repositoryの3層をどう組み合わせればいいか迷いますよね。アノテーションは個別に調べて分かったけど、全体をつないだ実装イメージがまだぼんやりしている、という方も多いと思います。

この記事では Item というシンプルなエンティティを題材に、GET/POST/PUT/DELETEの4エンドポイントを3層構成で最初から最後まで作り切ります。読み終えたら、同じ構成を自分のプロジェクトに適用できる状態になることを目指しましょう。

この記事で作るもの

完成形のエンドポイント一覧はこちらです。

メソッドパス概要
GET/items全件取得
GET/items/{id}1件取得
POST/items新規作成
PUT/items/{id}更新
DELETE/items/{id}削除

パッケージ構成はこのように分けます。

src/main/java/com/example/demo/
├── controller/
│   └── ItemController.java
├── service/
│   └── ItemService.java
├── repository/
│   └── ItemRepository.java
└── entity/
    └── Item.java

プロジェクトのセットアップ

Spring InitializrSpring WebSpring Data JPAH2 Database を選択してプロジェクトを作成します。pom.xmlには以下の依存関係が追加されます。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<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>

application.properties にH2の設定を追加しましょう。インメモリDBなので、開発中の動作確認にちょうどよいです。

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.h2.console.enabled=true
spring.jpa.show-sql=true

3層構成の責務を整理する

実装に入る前に各層の役割をはっきりさせておきましょう。

  • Controller — HTTPリクエストを受け取りレスポンスを返す入口。ビジネスロジックは書かない
  • Service — ビジネスロジックの実装場所。Repositoryを呼び出してデータ操作を行う
  • Repository — DBアクセスの抽象化。Spring Data JPAが実装を自動生成する

依存の向きは Controller → Service → Repository の一方向です。ControllerがRepositoryを直接使ったり、Controllerにビジネスロジックが混在したりすると、後からの変更が辛くなります。

エンティティクラスを定義する

package com.example.demo.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "items")
public class Item {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    private String description;

    // getter / setter は省略(Lombokの @Data でも可)
}

@Entity でJPAの管理対象になり、@Id@GeneratedValue でIDの自動採番が設定されます。

Repositoryを定義する

JpaRepository を継承するだけです。

package com.example.demo.repository;

import com.example.demo.entity.Item;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ItemRepository extends JpaRepository<Item, Long> {
}

これだけで findAll()findById()save()deleteById() が使えます。独自の検索条件が必要になったら Spring Data JPAのクエリメソッド記事 を参照してみてください。

Serviceクラスを実装する

package com.example.demo.service;

import com.example.demo.entity.Item;
import com.example.demo.repository.ItemRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ItemService {

    private final ItemRepository itemRepository;

    public ItemService(ItemRepository itemRepository) {
        this.itemRepository = itemRepository;
    }

    public List<Item> findAll() {
        return itemRepository.findAll();
    }

    public Item findById(Long id) {
        return itemRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Item not found: " + id));
    }

    public Item create(Item item) {
        return itemRepository.save(item);
    }

    public Item update(Long id, Item item) {
        Item existing = findById(id);
        existing.setName(item.getName());
        existing.setDescription(item.getDescription());
        return itemRepository.save(existing);
    }

    public void delete(Long id) {
        itemRepository.deleteById(id);
    }
}

コンストラクタインジェクションはSpring公式の推奨方法です。フィールドに @Autowired を付けるよりテストが書きやすくなります。

RuntimeException を投げている箇所は、実務では 例外処理の記事 で紹介しているカスタム例外と @ControllerAdvice で一元管理するのが一般的です。

Controllerを実装する

package com.example.demo.controller;

import com.example.demo.entity.Item;
import com.example.demo.service.ItemService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/items")
public class ItemController {

    private final ItemService itemService;

    public ItemController(ItemService itemService) {
        this.itemService = itemService;
    }

    @GetMapping
    public ResponseEntity<List<Item>> findAll() {
        return ResponseEntity.ok(itemService.findAll());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Item> findById(@PathVariable Long id) {
        return ResponseEntity.ok(itemService.findById(id));
    }

    @PostMapping
    public ResponseEntity<Item> create(@RequestBody Item item) {
        return ResponseEntity.status(HttpStatus.CREATED).body(itemService.create(item));
    }

    @PutMapping("/{id}")
    public ResponseEntity<Item> update(@PathVariable Long id, @RequestBody Item item) {
        return ResponseEntity.ok(itemService.update(id, item));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        itemService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

HTTPステータスコードの使い分けがポイントです。

  • POST201 Created(新しいリソースが作成された)
  • DELETE204 No Content(成功したがレスポンスボディなし)
  • 取得・更新の成功 → 200 OK

@RequestBody でリクエストのJSONをオブジェクトに変換し、@PathVariable でURL中の {id} を受け取ります。

入力バリデーションを追加したい場合は @RequestBody の前に @Valid を付けてエンティティに @NotBlank などを追加するだけです。詳しくは バリデーション記事 を参照してください。

curlで動作確認する

mvn spring-boot:run でアプリを起動して、curlで動作確認しましょう。

# データ作成(201 Created が返る)
curl -X POST http://localhost:8080/items \
  -H "Content-Type: application/json" \
  -d '{"name":"テスト商品","description":"説明文"}'

# 全件取得
curl http://localhost:8080/items

# 1件取得
curl http://localhost:8080/items/1

# 更新
curl -X PUT http://localhost:8080/items/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"更新後の商品","description":"更新済み"}'

# 削除(204 No Content が返る)
curl -X DELETE http://localhost:8080/items/1

H2コンソールは http://localhost:8080/h2-console からアクセスできます。JDBC URLは jdbc:h2:mem:testdb と入力してください。

次のステップ

基本的なCRUDができたら、実務に近づけるために以下を追加していきましょう。

まとめ

3層構成でCRUD APIを一通り実装しました。各層の責務を分けておくと、後から例外処理やバリデーションを追加するときにも変更箇所が明確になります。まずはこの構成を自分のプロジェクトで試してみて、少しずつ肉付けしていきましょう。