この記事は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を返す | 同上 |
| 7 | JUnit5でAPIテストを書く | テストコードの追加 |
| 8 | MySQLで永続化する | 課題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_URL・DB_USERNAME・DB_PASSWORD を設定する運用です。
これをクラウドワークスのプロフィールページの「ポートフォリオ」欄に貼り付けます。
まとめ:5課題で身についたJavaスキル
| 課題 | 主要スキル |
|---|---|
| 課題1 | 変数・条件分岐・文字列操作・Scanner |
| 課題2 | ファイルI/O・Stream API・groupingBy |
| 課題3 | HTTP通信・JSON解析・カスタム例外 |
| 課題4 | Spring Boot・JPA・DIコンテナ・バリデーション |
| 課題5 | MySQL・リレーション・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
サブスク加入者限定の
コンテンツを配信中
- 毎月のAI・自動化トレンドレポート
- クリエイター向け業務効率化テンプレート
- ツール選定・SaaS比較まとめ(限定公開)
「これ自動化できないかな?」
そのアイデア、ITで実現できます。
業務の自動化・お客様向けツール開発・AI活用まで、クリエイター・個人事業主専門のITコンサルタントが対応します。まずは気軽にご相談ください。