Spring Boot + MySQL 家計簿APIを作る|JPA・テスト・例外処理の本格実装

Spring Boot + MySQL 家計簿APIを作る|JPA・テスト・例外処理の本格実装

この記事はJava学習シリーズ「課題5(最終課題)」です。まだ環境が整っていない方は Java環境構築ガイド を先に読んでください。課題4(Spring Boot ToDoリストAPI)を先に完成させることを強く推奨します。


この記事で作るもの

MySQL連携の本格的な家計簿REST APIです。これをGitHubに公開してクラウドワークスのポートフォリオにします。

【エンドポイント】
GET    /api/categories              カテゴリ一覧
POST   /api/categories              カテゴリ作成
DELETE /api/categories/{id}         カテゴリ削除

GET    /api/transactions             収支一覧(ページネーション)
GET    /api/transactions?type=EXPENSE 支出のみ
GET    /api/transactions?year=2026&month=4 月別絞り込み
POST   /api/transactions             収支追加
PUT    /api/transactions/{id}        収支更新
DELETE /api/transactions/{id}        収支削除

GET    /api/summary?year=2026&month=4 月別収支サマリー

この記事で学べること

  • MySQL接続(application.properties設定)
  • テーブル間のリレーション(多対1、外部キー)
  • JUnit5テスト(MockMvcでAPIをテスト)
  • グローバル例外ハンドリング@ControllerAdvice
  • ページネーションPageable
  • JPQL(JPA独自のクエリ言語)

MySQLとは? 世界で最も広く使われているオープンソースのリレーショナルデータベース(RDB)です。データをExcelの表のように「行と列のテーブル」形式で管理します。WordPressやDrupalのデフォルトDBで、クラウドワークスのWebアプリ案件でも頻繁に使われます。

JUnit(ジェイユニット)とは? Javaのユニットテストフレームワークです。「ユニットテスト」とはプログラムの小さな単位(メソッドやクラス)が正しく動くかを自動で確認するテストのことです。@Test を付けたメソッドに「期待する結果」を書き、テストを実行すると自動で確認してくれます。クラウドワークス案件では「テスト込みでお願い」と指定されることが増えています。

ページネーション(Pagination)とは? 大量のデータを一度に返すとデータ量が大きすぎるため、「1ページ20件ずつ表示する」のように分割して返す仕組みです。「page=0&size=20」のようなパラメーターで「何ページ目の何件」を取得するかを指定します。


1. 要件定義:何を作るかを整理する

課題5は「クラウドワークスのポートフォリオになる本格的なAPI」が目標です。課題4のToDoリストAPIと比べて、以下の要件が追加されます。

#要件(やること)課題4との違い
1カテゴリと収支記録の2テーブルを管理するテーブル間のリレーション(多対1)が必要
2収支の一覧をページネーションで返す大量データ対応(課題4は全件返すだけ)
3年・月・種別(収入/支出)で絞り込みができるJPQL(JPA独自クエリ)が必要
4月別の収支サマリー(合計収入・合計支出・収支バランス)を返す集計クエリが必要
5存在しないIDへのアクセスで404を返すグローバル例外ハンドリングが必要
6バリデーションエラーで400を返す同上
7JUnit5でAPIテストを書くテストコードの追加
8MySQLで永続化する課題4のH2(インメモリ)から本番想定DBに変更

2. プロジェクト作成とMySQL準備

Spring Initializrでプロジェクト生成

Artifact: household-api
Dependencies:
  ✓ Spring Web
  ✓ Spring Data JPA
  ✓ MySQL Driver        ← 課題4のH2から変更
  ✓ Validation
  ✓ Lombok
  ✓ Spring Boot DevTools

MySQLのインストールとDB作成

# Homebrew でMySQL 8をインストール
brew install mysql@8.0
brew services start mysql@8.0

# MySQLに接続
mysql -u root

# DBとユーザーを作成
CREATE DATABASE household_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'household_user'@'localhost' IDENTIFIED BY 'あなたが決めたパスワード';
GRANT ALL PRIVILEGES ON household_db.* TO 'household_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;

環境変数ファイル .env を作成する

パスワードをコードに直書きしてはいけません。 GitHubに公開したときに認証情報が漏洩します。パスワードなどの機密情報は .env ファイルに書いて、.gitignore で管理対象から除外します。

プロジェクトルート(pom.xml があるフォルダ)に .env ファイルを作成します。

# .env ファイルの内容
DB_URL=jdbc:mysql://localhost:3306/household_db?useSSL=false&serverTimezone=Asia/Tokyo
DB_USERNAME=household_user
DB_PASSWORD=あなたが決めたパスワード

次に .gitignore.env を追加します(GitHubに上げないようにするため)。

# .gitignore に追記
.env
.env.local
.env.*

application.properties の設定

${変数名} という書き方で .env の値を参照します。

# MySQL接続設定(パスワードは環境変数から読み込む)
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA設定
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect

# JSON設定(LocalDateTimeをISO形式でシリアライズ)
spring.jackson.serialization.write-dates-as-timestamps=false

# Spring Bootが.envファイルを読み込むための設定
spring.config.import=optional:file:.env[.properties]

3. Entityクラスを2つ作る(リレーション)

家計簿は「カテゴリ」と「収支記録」の2つのテーブルで構成されます。

Category.java

package com.example.householdapi.entity;

import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "categories")
@Data
@NoArgsConstructor
public class Category {

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

    @Column(nullable = false, unique = true, length = 50)
    private String name;  // "食費" / "家賃" / "給与" など

    @Column(nullable = false)
    private String color = "#6366f1";  // カテゴリのカラーコード(UI表示用)

    public Category(String name) {
        this.name = name;
    }
}

Transaction.java(収支記録)

package com.example.householdapi.entity;

import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;

@Entity
@Table(name = "transactions")
@Data
@NoArgsConstructor
public class Transaction {

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

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TransactionType type;  // INCOME(収入)/ EXPENSE(支出)

    @Column(nullable = false)
    private int amount;  // 金額(円)

    // 多対1のリレーション: 複数のTransactionが1つのCategoryに属する
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;

    @Column(length = 200)
    private String description;

    @Column(nullable = false)
    private LocalDate date;  // 収支の発生日

    @Column(updatable = false)
    private LocalDateTime createdAt = LocalDateTime.now();

    public enum TransactionType {
        INCOME, EXPENSE
    }
}

@ManyToOne とは

テーブル間のリレーションをJPAで表現します。

categories テーブル(1)

transactions テーブル(多)← 1つのカテゴリに複数の収支が紐づく
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")  // transactionsテーブルのcategory_id列
private Category category;

FetchType.LAZY は「必要になったときだけDBから取得する」設定です。パフォーマンス面で重要な設定です(EAGERにすると常に一緒に取得)。


4. Repositoryを作る

package com.example.householdapi.repository;

import com.example.householdapi.entity.Transaction;
import com.example.householdapi.entity.Transaction.TransactionType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;

public interface TransactionRepository extends JpaRepository<Transaction, Long> {

    // 種別でフィルタリング(ページネーション対応)
    Page<Transaction> findByType(TransactionType type, Pageable pageable);

    // 年月でフィルタリング(JPQLクエリ)
    @Query("""
        SELECT t FROM Transaction t
        WHERE YEAR(t.date) = :year
          AND MONTH(t.date) = :month
        ORDER BY t.date DESC
        """)
    List<Transaction> findByYearAndMonth(
        @Param("year") int year,
        @Param("month") int month
    );

    // 月別の収入/支出合計(サマリー用)
    @Query("""
        SELECT SUM(t.amount) FROM Transaction t
        WHERE t.type = :type
          AND YEAR(t.date) = :year
          AND MONTH(t.date) = :month
        """)
    Integer sumByTypeAndYearAndMonth(
        @Param("type") TransactionType type,
        @Param("year") int year,
        @Param("month") int month
    );
}

JPQLとは

JPA独自のクエリ言語です。SQLと似ていますが、テーブル名の代わりにクラス名、列名の代わりにフィールド名を使います。

// SQL
SELECT * FROM transactions WHERE type = 'EXPENSE'

// JPQL
SELECT t FROM Transaction t WHERE t.type = :type

5. グローバル例外ハンドリング(要件5・6の実装)

要件5・6を実装します。「存在しないID」「バリデーションエラー」を適切な HTTPステータスコードで返す必要があります。

各Controllerにエラー処理を個別に書いてもいいですが、Controller が増えるたびに同じコードを書くのは非効率です。そこでグローバル例外ハンドリングを使います。

@ControllerAdvice とは? すべてのControllerに対して「横断的」に適用される処理を書くためのアノテーションです。@ExceptionHandler(ResourceNotFoundException.class) を付けたメソッドに「404を返す処理」を書いておけば、どのControllerで ResourceNotFoundException が発生しても、自動的にそのメソッドが呼ばれます。

これにより、エラーハンドリングを1箇所にまとめることができます。クラウドワークスの既存コードには必ず存在するパターンです。

package com.example.householdapi.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice  // 全Controllerに対してAOPで例外処理を適用
public class GlobalExceptionHandler {

    // リソースが見つからない場合(404)
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse(404, e.getMessage()));
    }

    // バリデーションエラー(400)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        Map<String, String> errors = new HashMap<>();
        for (FieldError error : e.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(new ErrorResponse(400, "入力値に誤りがあります", errors));
    }

    // 予期しないエラー(500)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse(500, "サーバーエラーが発生しました"));
    }

    // エラーレスポンスの形式
    public record ErrorResponse(int status, String message, Object details) {
        public ErrorResponse(int status, String message) {
            this(status, message, null);
        }
    }
}

ResourceNotFoundException.java(カスタム例外):

package com.example.householdapi.exception;

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String resource, Long id) {
        super(resource + " が見つかりません(ID: " + id + ")");
    }
}

6. Serviceの実装(月次サマリー含む)

package com.example.householdapi.service;

import com.example.householdapi.dto.*;
import com.example.householdapi.entity.Transaction;
import com.example.householdapi.entity.Transaction.TransactionType;
import com.example.householdapi.exception.ResourceNotFoundException;
import com.example.householdapi.repository.CategoryRepository;
import com.example.householdapi.repository.TransactionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class TransactionService {

    private final TransactionRepository transactionRepository;
    private final CategoryRepository categoryRepository;

    // 全件取得(ページネーション)
    public Page<TransactionResponse> findAll(Pageable pageable) {
        return transactionRepository.findAll(pageable)
            .map(TransactionResponse::from);
    }

    // 月別収支サマリー
    public MonthlySummary getSummary(int year, int month) {
        int totalIncome = orZero(
            transactionRepository.sumByTypeAndYearAndMonth(TransactionType.INCOME, year, month)
        );
        int totalExpense = orZero(
            transactionRepository.sumByTypeAndYearAndMonth(TransactionType.EXPENSE, year, month)
        );

        return new MonthlySummary(year, month, totalIncome, totalExpense, totalIncome - totalExpense);
    }

    // トランザクション付きで保存(DBへの書き込みは必ず @Transactional で囲む)
    @Transactional
    public TransactionResponse create(CreateTransactionRequest req) {
        var category = categoryRepository.findById(req.categoryId())
            .orElseThrow(() -> new ResourceNotFoundException("カテゴリ", req.categoryId()));

        Transaction transaction = new Transaction();
        transaction.setType(req.type());
        transaction.setAmount(req.amount());
        transaction.setCategory(category);
        transaction.setDescription(req.description());
        transaction.setDate(req.date());

        return TransactionResponse.from(transactionRepository.save(transaction));
    }

    @Transactional
    public void delete(Long id) {
        if (!transactionRepository.existsById(id)) {
            throw new ResourceNotFoundException("収支記録", id);
        }
        transactionRepository.deleteById(id);
    }

    private int orZero(Integer value) {
        return value != null ? value : 0;
    }
}

7. JUnit5でAPIテストを書く(要件7の実装)

要件7「JUnit5でAPIテストを書く」を実装します。

テストを書く理由は「修正したときに既存機能が壊れていないか自動で確認できるから」です。クラウドワークス案件では「既存のテストを壊さないこと」が暗黙の条件になっているケースも多くあります。

MockMvc(モックエムブイシー)とは? Spring Bootのテスト専用ツールで、実際のHTTPサーバーを起動せずにAPIの動作をシミュレートできます。mockMvc.perform(get("/api/transactions")) で「GETリクエストを送ったつもり」のテストができます。

@Transactional をテストクラスに付ける理由 テストが終わったあと、作成したデータをDBから自動的に削除(ロールバック)するためです。テストを何度実行してもDBが汚染されません。

package com.example.householdapi.controller;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
@Transactional  // テスト後にDBをロールバック(テストデータが残らない)
class TransactionControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("収支一覧を取得すると200が返る")
    void getAllTransactions_returns200() throws Exception {
        mockMvc.perform(get("/api/transactions"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON));
    }

    @Test
    @DisplayName("存在しないIDを取得すると404が返る")
    void getTransaction_notFound_returns404() throws Exception {
        mockMvc.perform(get("/api/transactions/9999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.status").value(404));
    }

    @Test
    @DisplayName("タイトルなしで作成すると400が返る")
    void createTransaction_invalidAmount_returns400() throws Exception {
        String invalidJson = """
            {
                "type": "EXPENSE",
                "amount": -100,
                "categoryId": 1,
                "date": "2026-04-10"
            }
            """;

        mockMvc.perform(post("/api/transactions")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidJson))
            .andExpect(status().isBadRequest());
    }

    @Test
    @DisplayName("月別サマリーAPIが正しい構造を返す")
    void getMonthlySummary_returnsValidStructure() throws Exception {
        mockMvc.perform(get("/api/summary?year=2026&month=4"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.year").value(2026))
            .andExpect(jsonPath("$.month").value(4))
            .andExpect(jsonPath("$.totalIncome").exists())
            .andExpect(jsonPath("$.totalExpense").exists())
            .andExpect(jsonPath("$.balance").exists());
    }

    @Test
    @DisplayName("存在しないカテゴリでトランザクション作成すると404が返る")
    void createTransaction_categoryNotFound_returns404() throws Exception {
        String json = """
            {
                "type": "EXPENSE",
                "amount": 1000,
                "categoryId": 9999,
                "date": "2026-04-10"
            }
            """;

        mockMvc.perform(post("/api/transactions")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
            .andExpect(status().isNotFound());
    }
}

テストの実行

IntelliJで TransactionControllerTest.java を右クリック → 「Run」。または:

./mvnw test

全テストが緑(PASSED)になれば成功です。


8. GitHubへの公開(ポートフォリオ化)

README.mdを作成する

プロジェクトルートに README.md を作成し、以下の内容を記載します。

# 家計簿REST API

Spring Boot + MySQL で作成した家計簿管理REST APIです。

## 技術スタック

- Java 21
- Spring Boot 3.3
- Spring Data JPA
- MySQL 8.0
- Lombok
- JUnit5

## 主要エンドポイント

| メソッド | パス | 説明 |
|--------|------|------|
| GET | /api/transactions | 収支一覧(ページネーション) |
| POST | /api/transactions | 収支追加 |
| GET | /api/summary | 月別収支サマリー |

## セットアップ

\`\`\`bash
# MySQL起動後
mysql -u root -e "CREATE DATABASE household_db;"
# application.propertiesの認証情報を設定
./mvnw spring-boot:run
\`\`\`

GitHubにpushする手順

# .gitignore に .env が含まれているか確認(含まれていなければ追加)
cat .gitignore | grep ".env"

# GitHubでリポジトリを新規作成(household-api)
git init
git add .
git commit -m "first commit: 家計簿REST API(Spring Boot + MySQL)"
git remote add origin https://github.com/YOUR_NAME/household-api.git
git push -u origin main

重要: git add . の前に .env がステージングに含まれていないか確認してください。git status.env が表示されていたら、.gitignore の設定を見直してください。

Railway(無料デプロイ)で公開する場合:

Railway などのクラウドサービスにデプロイするときは、.env の内容をサービスの「環境変数設定」画面から入力します。コードに直書きせず、デプロイ先のダッシュボードで DB_URLDB_USERNAMEDB_PASSWORD を設定する運用です。

これをクラウドワークスのプロフィールページの「ポートフォリオ」欄に貼り付けます。


まとめ:5課題で身についたJavaスキル

課題主要スキル
課題1変数・条件分岐・文字列操作・Scanner
課題2ファイルI/O・Stream API・groupingBy
課題3HTTP通信・JSON解析・カスタム例外
課題4Spring Boot・JPA・DIコンテナ・バリデーション
課題5MySQL・リレーション・JUnit5・@ControllerAdvice・ページネーション

課題1〜5をすべて完成させた時点で、クラウドワークスの初級〜中級Java案件(5,000〜30,000円規模)に応募できる基礎力が身についています。

次のステップ(課題5の発展):

  • Docker + docker-compose でMySQL環境をコンテナ化
  • Spring Security でAPIに認証を追加
  • Swagger/OpenAPI でAPIドキュメントを自動生成

Javaの学習シリーズ: 環境構築 | 課題1・パスワードチェッカー | 課題2・CSV集計ツール | 課題3・為替APIクライアント | 課題4・Spring Boot Todo API | 今ここ(課題5・家計簿API)

この記事をシェアする

関連記事


CHECK IT OUT

Members Only

サブスク加入者限定の コンテンツを配信中

  • 毎月のAI・自動化トレンドレポート
  • クリエイター向け業務効率化テンプレート
  • ツール選定・SaaS比較まとめ(限定公開)
Discord サーバー & LINE 公式の両方で通知します
クリエイター・個人事業主向け 受付中

「これ自動化できないかな?」 そのアイデア、ITで実現できます。

業務の自動化・お客様向けツール開発・AI活用まで、クリエイター・個人事業主専門のITコンサルタントが対応します。まずは気軽にご相談ください。

LINEで相談 フォームで相談