2026年6月1日月曜日

SpringBoot でエラーハンドリングする方法

SpringBoot でエラーハンドリングする方法

概要

これまではエラーをそのまま返却しており SpringBoot 側はハンドリングされていないエラーはすべて 500 エラーで返します

今回は特定のエラーをハンドリングしエラーレスポンスをカスタムする方法を紹介します

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
    • jasypt-spring-boot 4.0.4
    • jackson-databind 2.21.2
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0
  • Redis 8.6.3

カスタムエラーの作成

まずはカスタムエラーを作成します
独自の Exception になります

  • vim src/main/java/com/example/demo/exception/BusinessException.java
package com.example.demo.exception;

public class BusinessException extends RuntimeException {

	private final String code;

	public BusinessException(String code, String message) {
		super(message);
		this.code = code;
	}

	public String getCode() {
		return code;
	}
}

エラーレスポンス用の DTO の作成

レスポンス用の DTO を作成します
エラーレスポンスとして返却したい情報を管理します

  • vim src/main/java/com/example/demo/exception/dto/ErrorResponse.java
package com.example.demo.exception.dto;

import java.time.LocalDateTime;

public class ErrorResponse {

	private String code;
	private String message;
	private LocalDateTime timestamp;

	public ErrorResponse(String code, String message, LocalDateTime timestamp) {
		this.code = code;
		this.message = message;
		this.timestamp = timestamp;
	}

	public String getCode() {
		return code;
	}

	public String getMessage() {
		return message;
	}

	public LocalDateTime getTimestamp() {
		return timestamp;
	}
}

エラーハンドラの作成

これがメイン部分です
SpringBoot では @RestControllerAdvice を基本的に使います
このアノテーションがあるクラスを自動的に読み込みエラーハンドリングしてくれます

  • vim src/main/java/com/example/demo/exception/GlobalExceptionHandler.java
package com.example.demo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import com.example.demo.exception.dto.ErrorResponse;

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.time.LocalDateTime;

@RestControllerAdvice
public class GlobalExceptionHandler {

	@ExceptionHandler(BusinessException.class)
	@ResponseStatus(HttpStatus.BAD_REQUEST)
	public ErrorResponse handleBusinessException(BusinessException ex) {
		return new ErrorResponse(ex.getCode(), ex.getMessage(), LocalDateTime.now());
	}

	@ExceptionHandler(Exception.class)
	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
	public ErrorResponse handleException(Exception ex) {
		return new ErrorResponse("INTERNAL_SERVER_ERROR", "予期しないエラーが発生しました", LocalDateTime.now());
	}
}

サービス側修正

サービス側は作成したカスタム Exception を返却するようにします

  • vim src/main/java/com/example/demo/service/UserService.java
package com.example.demo.service;

import java.util.ArrayList;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import com.example.demo.dto.RedisMessageRequest;
import com.example.demo.dto.UserRequest;
import com.example.demo.dto.UserResponse;
import com.example.demo.entity.User;
import com.example.demo.exception.BusinessException;
import com.example.demo.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;

@Service
public class UserService {

	@Autowired
	private UserRepository userRepository;

	@Autowired
	private StringRedisTemplate redisTemplate;

	@Autowired
	private ObjectMapper objectMapper;

	public void createUser(UserRequest req) {

		// 例:ビジネスルール
		if (userRepository.existsByEmail(req.getEmail())) {
			throw new BusinessException("USER_ALREADY_EXISTS", "既に登録されています");
		}

		User user = new User();
		user.setName(req.getName());
		user.setEmail(req.getEmail());

		userRepository.save(user);

		// Redis キューにメッセージを JSON 形式で送信
		try {
			RedisMessageRequest message = new RedisMessageRequest();
			message.setName(req.getName());
			message.setEmail(req.getEmail());

			String jsonMessage = objectMapper.writeValueAsString(message);
			redisTemplate.convertAndSend("chat", jsonMessage);
		} catch (Exception e) {
			throw new RuntimeException("Failed to send Redis message", e);
		}
	}

	public List<UserResponse> getAllUsers() {
		List<User> users = new ArrayList<>();
		userRepository.findAll().forEach(users::add);

		return users.stream().map(this::toResponse).toList();
	}

	private UserResponse toResponse(User user) {
		UserResponse response = new UserResponse();
		response.setName(user.getName());
		return response;
	}
}

動作確認

  • ./gradlew clean && ./gradlew bootRun --args='--jasypt.encryptor.password=xxx'
  • curl -XPOST http://localhost:8080/demo/add -d '{"name": "First", "email": "someemail5@someemailprovider.com"}' -H 'content-type: application/json'
{"code":"USER_ALREADY_EXISTS","message":"既に登録されています","timestamp":"2026-05-25T13:32:16.13214"}

最後に

SpringBoot でエラーハンドリングする方法を紹介しました
@RestControllerAdvice を使いましょう

2026年5月31日日曜日

SpringBoot で環境ごとに設定を変える方法

SpringBoot で環境ごとに設定を変える方法

概要

SpringBoot にはデフォルトで環境ごとに設定を出し分ける機能があるのでそれを使うのが一番簡単です
--spring.profiles.active オプションを使います

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0

src/main/resources/application.properties

spring.application.name=demo
# update はアプリケーション起動時に、Entityに対応するテーブルがなければ作成します
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/mydatabase
spring.datasource.username=root
spring.datasource.password=

src/main/resources/application-dev.properties

spring.application.name=demo
# update はアプリケーション起動時に、Entityに対応するテーブルがなければ作成します
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/mydatabase_dev
spring.datasource.username=root
spring.datasource.password=

build や bootRun 時にオプションを付与する

上を使う場合には

  • ./gradlew build
  • ./gradlew bootRun

下を使う場合には

  • ./gradlew build
  • java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev
  • ./gradlew bootRun --args='--spring.profiles.active=dev'

という感じで出し分けることができます

環境変数でもいける

  • SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun

build 時の -P オプションでもいける

ただこれの場合は工夫が必要です
build.gradle で -P オプションで指定された引数の値を受け取り出し分けます

  • vim build.gradle
def env = project.findProperty("env") ?: "dev"

tasks.withType(JavaExec) {
    systemProperty "spring.profiles.active", env
}
  • ./gradlew build -Penv=dev

おまけ: ログにansiカラーを入れる場合

  • ./gradlew bootRun --args='--spring.profiles.active=dev --spring.output.ansi.enabled=always'

最後に

基本は spring.profiles.active オプションを使うのが良いと思います

参考サイト

2026年5月29日金曜日

SpringBoot + VSCode 環境で補完してくれないときの対処方法

SpringBoot + VSCode 環境で補完してくれないときの対処方法

概要

Gradle プロジェクトの場合には追加で拡張をインストールする必要があります

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
  • jasypt-spring-boot 4.0.4
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0

すでにインストールしている拡張

  • Extension Pack for Java
  • Spring Boot Extension Pack

追加でインストールする拡張

  • Extension Pack for Java Auto Config
  • Gradle Language Support

もしくは settings.json を見直す

{
    "java.jdt.ls.java.home": "/opt/homebrew/Cellar/openjdk/26.0.1/libexec/openjdk.jdk/Contents/Home",
    "java.configuration.runtimes": [
        {
            "name": "JavaSE-26",
            "path": "/opt/homebrew/Cellar/openjdk/26.0.1/libexec/openjdk.jdk/Contents/Home",
            "default": true
        }
    ],
    "java.import.gradle.home": "/opt/homebrew/Cellar/gradle/9.5.1/",
    "java.import.gradle.java.home": "/opt/homebrew/Cellar/openjdk/26.0.1/libexec/openjdk.jdk/Contents/Home"
}

動作確認

VSCodeを再起動するかコマンドパレットで「Clean Java Language Server Workspace」すれば補完ができるようになるはずです

最後に

おそらく Gradle Language Server 拡張は自動でプロジェクトの java/gradle を使ってくれるようです

逆に Language Server for Java は組み込まれている java/gradle を使うのでインストールした gradle のファイルなどが読み込まれていないので補完できていなかったのかなと思います

参考サイト

2026年5月28日木曜日

SpringBoot でユニットテストとコンテナを使ったインテグレーションテストを作成する

SpringBoot でユニットテストとコンテナを使ったインテグレーションテストを作成する

概要

前回 DTO + Service レイヤーを追加しました
アプリがこの辺りまで大きくなったら一度テストを追加することをオススメします

SpringBoot にはコンテナを使って MySQL を起動しそれをインテグレーションテスト用として使えます

今回は MySQL を使わないでモックするユニットテストと MySQL コンテナを起動し実際にデータベースを使ったインテグレーションテストを追加する方法を紹介します

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
  • jasypt-spring-boot 4.0.4
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0

コントローラのユニットテストの追加

コントローラでは Service をモックします
Service が期待する値をモックにセットしあとは実際にリクエストを送信しその値が返ってくるか検証します

テスト内では検証用の static method を大量に使うのでそこはアスタリスクでインポートしています

  • vim src/test/java/com/example/demo/MainControllerTest.java
package com.example.demo;

import java.util.List;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

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

import com.example.demo.dto.UserResponse;
import com.example.demo.service.UserService;

@WebMvcTest(MainController.class)
class MainControllerTest {

	@Autowired
	private MockMvc mockMvc;

	@MockitoBean
	private UserService userService;

	@Test
	void ユーザー作成が成功する() throws Exception {
		String json = """
				{
				    "name": "taro",
				    "email": "taro@example.com"
				}
				""";

		mockMvc.perform(post("/demo/add").contentType("application/json").content(json)).andExpect(status().isOk())
				.andExpect(content().string("Saved"));
	}

	@Test
	void ユーザー一覧取得() throws Exception {
		UserResponse user = new UserResponse();
		user.setName("taro");
		List<UserResponse> mock = List.of(user);

		when(userService.getAllUsers()).thenReturn(mock);

		mockMvc.perform(get("/demo/all")).andExpect(status().isOk()).andExpect(jsonPath("$[0].name").value("taro"));
	}
}

サービスのユニットテストの追加

サービスでは Repository をモックします
これも同様に Repository が返却時に期待する値をモックにセットしサービス内のメソッドをコールすることでテストします

verify はメソッドがコールされたかの振る舞いをテストするためのアサーションです

  • vim src/test/java/com/example/demo/service/UserServiceTest.java
package com.example.demo.service;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

import java.util.List;

import com.example.demo.dto.UserRequest;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

	@Mock
	private UserRepository userRepository;

	@InjectMocks
	private UserService userService;

	@Test
	void ユーザー作成できる() {
		UserRequest req = new UserRequest();
		req.setName("taro");
		req.setEmail("taro@example.com");

		userService.createUser(req);

		verify(userRepository).save(any(User.class));
	}

	@Test
	void findAllメソッドが呼ばれたか() {
		userService.getAllUsers();

		verify(userRepository).findAll();
	}

	@Test
	void ユーザーの一覧が取得できる() {
		User user = new User();
		user.setName("taro");
		user.setEmail("taro@example.com");

		when(userRepository.findAll()).thenReturn(List.of(user));

		var result = userService.getAllUsers();

		assertEquals(1, result.size());
		assertEquals("taro", result.get(0).getName());
	}

	@Test
	void メールが重複している場合はエラー() {
		UserRequest req = new UserRequest();
		req.setName("taro");
		req.setEmail("taro@example.com");

		// モックの振る舞いを定義
		when(userRepository.existsByEmail("taro@example.com")).thenReturn(true);

		// 例外が投げられることを検証
		IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> userService.createUser(req));

		assertEquals("既に登録されています", ex.getMessage());

		// saveが呼ばれていないことも重要
		verify(userRepository, never()).save(any());
	}
}

リポジトリのインテグレーションテストの追加

実際に MySQL コンテナに接続しテストします
MySQL を起動するのは org.testcontainers.mysql.MySQLContainer を使います

接続先情報やデータベースの作成はテスト時に自動で行ってくれます
コンテナを起動したりマイグレーションするので少しテストに時間がかかります

  • vim src/test/java/com/example/demo/repository/UserRepositoryIntegrationTest.java
package com.example.demo.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;

import com.example.demo.entity.User;

@Import(TestcontainersConfiguration.class)
@SpringBootTest
class UserRepositoryIntegrationTest {

	@Autowired
	private UserRepository userRepository;

	@Test
	void DBに保存できる() {
		User user = new User();
		user.setName("taro");
		user.setEmail("taro@example.com");

		userRepository.save(user);

		Iterable<User> users = userRepository.findAll();
		assertThat(users).hasSize(1);
	}
}

MySQL コンテナを起動するテストの設定は以下で行います

  • vim src/test/java/com/example/demo/repository/TestcontainersConfiguration.java
package com.example.demo.repository;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.mysql.MySQLContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

	@Bean
	@ServiceConnection
	MySQLContainer mysqlContainer() {
		return new MySQLContainer(DockerImageName.parse("mysql:latest"));
	}

}

動作確認

  • JASYPT_PASSWORD="xxx" ./gradlew test

JASYPT_PASSWORD は jasypt で application.properties を暗号化している場合に指定してください
テストが完了すれば OK です
2回目以降はキャッシュを参照してしまいテストが速攻で終了してしまうのでその場合は

  • JASYPT_PASSWORD="xxx" ./gradlew cleanTest test

最後に

SpringBoot でユニットテストとインテグレーションテストを作成する方法を紹介しました
ある程度アプリが大きくなる前に全体を通してテストするケースは作成しておきましょう

値の検証や境界値などテストパターンが不十分なのでその辺りはいろいろ追加してください

また異常系のテストも不足しているので必要に応じて追加してください

2026年5月27日水曜日

SpringBoot でレスポンスに DTO を使うサンプルコード

SpringBoot でレスポンスに DTO を使うサンプルコード

概要

前回リクエスト情報を DTO に変換して安全にデータベースの情報を取得する方法を紹介しました

今回はレスポンスでも DTO を導入してみます

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
  • jasypt-spring-boot 4.0.4
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0

レスポンス用 DTO の作成

レスポンス用の DTO には返却したい情報だけを定義します

今回はテストなので name だけ返却するようにします

  • vim src/main/java/com/example/demo/dto/UserResponse.java
package com.example.demo.dto;

import jakarta.validation.constraints.NotBlank;

public class UserResponse {

	@NotBlank
	private String name;

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

}

UserService の修正

getAllUsers を先ほど作成した UserResponse が帰るように修正します

stream を使ってクロージャっぽく変換しています

  • vim src/main/java/com/example/demo/service/UserService.java
package com.example.demo.service;

import java.util.ArrayList;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.example.demo.dto.UserRequest;
import com.example.demo.dto.UserResponse;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;

@Service
public class UserService {

	@Autowired
	private UserRepository userRepository;

	public void createUser(UserRequest req) {

		// 例:ビジネスルール
		if (userRepository.existsByEmail(req.getEmail())) {
			throw new IllegalArgumentException("既に登録されています");
		}

		User user = new User();
		user.setName(req.getName());
		user.setEmail(req.getEmail());

		userRepository.save(user);
	}

	public List<UserResponse> getAllUsers() {
		List<User> users = new ArrayList<>();
		userRepository.findAll().forEach(users::add);

		return users.stream().map(this::toResponse).toList();
	}

	private UserResponse toResponse(User user) {
		UserResponse response = new UserResponse();
		response.setName(user.getName());
		return response;
	}
}

コントローラの修正

コントローラからは Service レイヤーのみを扱うように修正します
これでコントローラが直接 Repository を扱うことはなくなりました

  • vim src/main/java/com/example/demo/MainController.java
package com.example.demo;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.example.demo.dto.UserRequest;
import com.example.demo.dto.UserResponse;
import com.example.demo.service.UserService;

import jakarta.validation.Valid;

@Controller
@RequestMapping(path = "/demo")
public class MainController {

	@Autowired
	private UserService userService;

	@PostMapping(path = "/add")
	public @ResponseBody String addNewUser(@Valid @RequestBody UserRequest req) {
		userService.createUser(req);
		return "Saved";
	}

	@GetMapping(path = "/all")
	public @ResponseBody List<UserResponse> getAllUsers() {
		return userService.getAllUsers();
	}
}

動作確認

  • ./gradlew bootRun --args='--jasypt.encryptor.password=xxx'

curl で GET し UserResponse に定義されている値だけ返ってくることを確認しましょう

  • curl -XGET http://localhost:8080/demo/all
[{"name":"First"}]

最後に

SpringBoot でレスポンスに DTO を導入してみました
レスポンスに DTO を導入するメリットとしては以下のようなものがあります

  • 予期せぬデータベース情報の返却を防ぐ
  • データベースの情報を加工してレスポンスを生成する

基本的にはレスポンスにも DTO を含めるのがベストプラクティスにはなります

2026年5月26日火曜日

SpringBoot でリクエスト情報を DTO に使うサンプルコード

SpringBoot でリクエスト情報を DTO に使うサンプルコード

概要

前回 SpringBoot から MySQL に接続する方法を紹介しました
その際はリクエストの情報をそのまま Entity に渡していましたがそれだといろいろと脆弱です (マスアサインメントなど)

なので通常は DTO(Data Transfer Object) と呼ばれる内部で扱うクラスに変換してから Entity に渡します

今回は DTO を使ったサンプルコードを紹介します
更に応用として Service レイヤーを追加して Repository とまとめる方法も紹介します

環境

  • macOS 26.4.1
  • openjdk 26.0.1
  • SpringBoot 4.0.6
  • jasypt-spring-boot 4.0.4
  • gradle 9.5.1
  • VSCode 1.121.0
  • MySQL 9.6.0

バリデーション用のライブラリ追加

spring-boot-starter-validation を使うので以下を追加します
バージョンは plugins 側で制御しています

  • vim build.gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '4.0.6'
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-validation'
}

DTO 用のクラスを作成する

基本的にはここでリクエスト値などのバリデーションを行います
今回はバリデーション用のライブラリにある基本的なバリデーションのみを追加します

  • vim src/main/java/com/example/demo/dto/UserRequest.java
package com.example.demo.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public class UserRequest {

    @NotBlank
    private String name;

    @Email
    private String email;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

コントローラの修正

コントローラ側ではリクエスト情報を先程の RequestUser クラスに自動的に変換するように修正します
@Valid, @RequestBody を使います

  • vim src/main/java/com/example/demo/MainController.java
package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.example.demo.dto.UserRequest;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;

import jakarta.validation.Valid;

@Controller
@RequestMapping(path = "/demo")
public class MainController {
	@Autowired
	private UserRepository userRepository;

	@PostMapping(path = "/add")
	public @ResponseBody String addNewUser(
			@Valid @RequestBody UserRequest req) {
		User user = new User();
		user.setName(req.getName());
		user.setEmail(req.getEmail());

		userRepository.save(user);
		return "Saved";
	}

	@GetMapping(path = "/all")
	public @ResponseBody Iterable<User> getAllUsers() {
		return userRepository.findAll();
	}
}

とりあえずここで動作確認

まずはここでちゃんと動作するか確認します

  • ./gradlew bootRun --args='--jasypt.encryptor.password=xxx'

application.properties を暗号化しているので jasypt.encryptor.password を指定していますが暗号化していなければ不要です

curl で動作確認しましょう

  • curl -XPOST http://localhost:8080/demo/add -d '{"name": "First", "email": "someemail@someemailprovider.com"}' -H 'content-type: application/json'

正常系は上記です
バリデーションエラーの確認をする場合は以下のようにリクエストしてみましょう

  • curl -XPOST http://localhost:8080/demo/add -d '{"name": "First", "email": "error_email"}' -H 'content-type: application/json'
{"timestamp":"2026-05-23T00:54:34.921Z","status":400,"error":"Bad Request","path":"/demo/add"}

コンソールのエラーメッセージには以下のように表示されると思います

2026-05-23T09:54:34.907+09:00  WARN 69601 --- [demo] [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public java.lang.String com.example.demo.MainController.addNewUser(com.example.demo.dto.UserRequest): [Field error in object 'userRequest' on field 'email': rejected value [error_email]; codes [Email.userRequest.email,Email.email,Email.java.lang.String,Email]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userRequest.email,email]; arguments []; default message [email],[Ljakarta.validation.constraints.Pattern$Flag;@2601ac31,.*]; default message [電子メールアドレスとして正しい形式にしてください]] ]

更に Service レイヤーを追加する

これでも十分ですが通常は更に Service レイヤーを追加します
Service レイヤーでは Repository を扱い受け取った UserRequest を使用します
基本的には何かしらのビジネスルールなどを実装する必要があります

  • vim src/main/java/com/example/demo/service/UserService.java
package com.example.demo.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.example.demo.dto.UserRequest;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;

@Service
public class UserService {

	@Autowired
	private UserRepository userRepository;

	public void createUser(UserRequest req) {

		// 例:ビジネスルール
		if (userRepository.existsByEmail(req.getEmail())) {
			throw new IllegalArgumentException("既に登録されています");
		}

		User user = new User();
		user.setName(req.getName());
		user.setEmail(req.getEmail());

		userRepository.save(user);
	}

	public Iterable<User> getAllUsers() {
		return userRepository.findAll();
	}
}

UserRepository.java 修正

ビジネスロジック用の処理を追加します

  • vim src/main/java/com/example/demo/repository/UserRepository.java
package com.example.demo.repository;

import org.springframework.data.repository.CrudRepository;

import com.example.demo.entity.User;

public interface UserRepository extends CrudRepository<User, Integer> {
	boolean existsByEmail(String email);
}

MainController の修正

MainController で Repository は扱わずに Service を扱うように修正します

  • vim src/main/java/com/example/demo/MainController.java
package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.example.demo.dto.UserRequest;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;

import jakarta.validation.Valid;

@Controller
@RequestMapping(path = "/demo")
public class MainController {

	@Autowired
	private UserService userService;

	@PostMapping(path = "/add")
	public @ResponseBody String addNewUser(@Valid @RequestBody UserRequest req) {
		userService.createUser(req);
		return "Saved";
	}

	@GetMapping(path = "/all")
	public @ResponseBody Iterable<User> getAllUsers() {
		return userService.getAllUsers();
	}
}

動作確認

  • curl -XPOST http://localhost:8080/demo/add -d '{"name": "First", "email": "someemail2@someemailprovider.com"}' -H 'content-type: application/json'
  • curl http://localhost:8080/demo/all

同じメールアドレスで登録しようとするとエラーになります
現状は特にエラーを指定していないので500エラーになります

  • curl -XPOST http://localhost:8080/demo/add -d '{"name": "First", "email": "someemail@someemailprovider.com"}' -H 'content-type: application/json'
{"timestamp":"2026-05-23T01:50:23.001Z","status":500,"error":"Internal Server Error","path":"/demo/add"}

最後に

SpringBoot で DTO を使って直接リクエスト情報を扱わない方法を紹介しました
Service レイヤーを入れる場合にはビジネスロジックも実装するようにしましょう

個人的にメールアドレスの重複チェックはどちらかと言えばバリデーションに近いのではと思うのですがデータベースを使うバリデーションはビジネスロジックになるようでその場合は Service でバリデーションするのが良いようです

レスポンスに関しても DTO を使うのが定石なので次回はレスポンス用の DTO を作成し使うように修正します

参考サイト