diff --git a/spring-reactive-modules/spring-reactive-exceptions/pom.xml b/spring-reactive-modules/spring-reactive-exceptions/pom.xml
index fc08e07659..940ae90de3 100644
--- a/spring-reactive-modules/spring-reactive-exceptions/pom.xml
+++ b/spring-reactive-modules/spring-reactive-exceptions/pom.xml
@@ -20,7 +20,10 @@
org.springframework.boot
spring-boot-starter-webflux
-
+
+ org.springframework.boot
+ spring-boot-starter-web
+
org.projectlombok
lombok
@@ -35,6 +38,11 @@
reactor-test
test
+
+ org.springframework
+ spring-test
+ test
+
@@ -42,8 +50,12 @@
org.springframework.boot
spring-boot-maven-plugin
+
+ com.baeldung.spring.reactive.customexception.CustomExceptionApplication
+ JAR
+
-
\ No newline at end of file
+
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/CustomExceptionApplication.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/CustomExceptionApplication.java
new file mode 100644
index 0000000000..336347a50b
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/CustomExceptionApplication.java
@@ -0,0 +1,13 @@
+package com.baeldung.spring.reactive.customexception;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class CustomExceptionApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(CustomExceptionApplication.class, args);
+ }
+
+}
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ErrorDetailsSerializer.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ErrorDetailsSerializer.java
new file mode 100644
index 0000000000..232226bd2c
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ErrorDetailsSerializer.java
@@ -0,0 +1,21 @@
+package com.baeldung.spring.reactive.customexception.config;
+
+import java.io.IOException;
+
+import com.baeldung.spring.reactive.customexception.model.ErrorDetails;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+public class ErrorDetailsSerializer extends JsonSerializer {
+ @Override
+ public void serialize(ErrorDetails value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+ gen.writeStartObject();
+ gen.writeStringField("code", value.getErrorCode()
+ .toString());
+ gen.writeStringField("message", value.getErrorMessage());
+ gen.writeStringField("reference", value.getReferenceUrl());
+ gen.writeEndObject();
+ }
+}
+
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ProblemDetailsSerializer.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ProblemDetailsSerializer.java
new file mode 100644
index 0000000000..2947058fac
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ProblemDetailsSerializer.java
@@ -0,0 +1,25 @@
+package com.baeldung.spring.reactive.customexception.config;
+
+import java.io.IOException;
+
+import org.springframework.http.ProblemDetail;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+public class ProblemDetailsSerializer extends JsonSerializer {
+
+ @Override
+ public void serialize(ProblemDetail value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+ gen.writeStartObject();
+ gen.writeObjectField("type", value.getType());
+ gen.writeObjectField("title", value.getTitle());
+ gen.writeObjectField("status", value.getStatus());
+ gen.writeObjectField("detail", value.getDetail());
+ gen.writeObjectField("instance", value.getInstance());
+ gen.writeObjectField("errors", value.getProperties().get("errors"));
+ gen.writeEndObject();
+ }
+}
+
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/controller/UserController.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/controller/UserController.java
new file mode 100644
index 0000000000..c71180f750
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/controller/UserController.java
@@ -0,0 +1,57 @@
+package com.baeldung.spring.reactive.customexception.controller;
+
+import java.time.OffsetDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.baeldung.spring.reactive.customexception.exception.CustomErrorException;
+import com.baeldung.spring.reactive.customexception.exception.UserNotFoundException;
+import com.baeldung.spring.reactive.customexception.model.CustomErrorResponse;
+import com.baeldung.spring.reactive.customexception.model.ErrorDetails;
+import com.baeldung.spring.reactive.customexception.model.User;
+
+import reactor.core.publisher.Mono;
+
+@RestController
+public class UserController {
+
+ private Map userMap = new HashMap<>();
+
+ @GetMapping("/v1/users/{userId}")
+ public Mono> getV1UserById(@PathVariable Long userId) {
+ return Mono.fromCallable(() -> {
+ User user = userMap.get(userId);
+ if (user == null) {
+ throw new UserNotFoundException("User not found with ID: " + userId);
+ }
+ return new ResponseEntity<>(user, HttpStatus.OK);
+ });
+ }
+
+ @GetMapping("/v2/users/{userId}")
+ public Mono> getV2UserById(@PathVariable Long userId) {
+ return Mono.fromCallable(() -> {
+ User user = userMap.get(userId);
+ if (user == null) {
+ CustomErrorResponse customErrorResponse = CustomErrorResponse
+ .builder()
+ .traceId(UUID.randomUUID().toString())
+ .timestamp(OffsetDateTime.now().now())
+ .status(HttpStatus.NOT_FOUND)
+ .errors(List.of(ErrorDetails.API_USER_NOT_FOUND))
+ .build();
+ throw new CustomErrorException("User not found", customErrorResponse);
+ }
+ return new ResponseEntity<>(user, HttpStatus.OK);
+ });
+ }
+
+}
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/CustomErrorException.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/CustomErrorException.java
new file mode 100644
index 0000000000..3ec4f6099e
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/CustomErrorException.java
@@ -0,0 +1,16 @@
+package com.baeldung.spring.reactive.customexception.exception;
+
+import com.baeldung.spring.reactive.customexception.model.CustomErrorResponse;
+
+import lombok.Getter;
+
+
+public class CustomErrorException extends RuntimeException {
+ @Getter
+ private CustomErrorResponse errorResponse;
+
+ public CustomErrorException(String message, CustomErrorResponse errorResponse) {
+ super(message);
+ this.errorResponse = errorResponse;
+ }
+}
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/GlobalExceptionHandler.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000000..33d6655e79
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/GlobalExceptionHandler.java
@@ -0,0 +1,34 @@
+package com.baeldung.spring.reactive.customexception.exception;
+
+import java.net.URI;
+import java.util.List;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler;
+
+import com.baeldung.spring.reactive.customexception.model.CustomErrorResponse;
+import com.baeldung.spring.reactive.customexception.model.ErrorDetails;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
+ @ExceptionHandler(UserNotFoundException.class)
+ protected ProblemDetail handleNotFound(RuntimeException ex) {
+ ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
+ problemDetail.setTitle("User not found");
+ problemDetail.setType(URI.create("https://example.com/problems/user-not-found"));
+ problemDetail.setProperty("errors", List.of(ErrorDetails.API_USER_NOT_FOUND));
+ return problemDetail;
+ }
+
+ @ExceptionHandler(CustomErrorException.class)
+ protected ResponseEntity handleCustomError(RuntimeException ex) {
+ CustomErrorException customErrorException = (CustomErrorException) ex;
+ return ResponseEntity.status(customErrorException.getErrorResponse().getStatus())
+ .body(customErrorException.getErrorResponse());
+ }
+
+}
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/UserNotFoundException.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/UserNotFoundException.java
new file mode 100644
index 0000000000..7306491bad
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/UserNotFoundException.java
@@ -0,0 +1,9 @@
+package com.baeldung.spring.reactive.customexception.exception;
+
+public class UserNotFoundException extends RuntimeException {
+
+ public UserNotFoundException(String message) {
+ super(message);
+ }
+
+}
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/CustomErrorResponse.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/CustomErrorResponse.java
new file mode 100644
index 0000000000..2159725a2d
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/CustomErrorResponse.java
@@ -0,0 +1,22 @@
+package com.baeldung.spring.reactive.customexception.model;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class CustomErrorResponse {
+ private String traceId;
+ private OffsetDateTime timestamp;
+ private HttpStatus status;
+ private List errors;
+}
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/Error.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/Error.java
new file mode 100644
index 0000000000..2c3f5c4024
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/Error.java
@@ -0,0 +1,20 @@
+package com.baeldung.spring.reactive.customexception.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class Error {
+
+ private int code;
+
+ private String message;
+
+ protected String reference;
+
+}
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/ErrorDetails.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/ErrorDetails.java
new file mode 100644
index 0000000000..a4ca566c71
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/ErrorDetails.java
@@ -0,0 +1,25 @@
+package com.baeldung.spring.reactive.customexception.model;
+
+
+import com.baeldung.spring.reactive.customexception.config.ErrorDetailsSerializer;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import lombok.Getter;
+
+@JsonSerialize(using = ErrorDetailsSerializer.class)
+public enum ErrorDetails {
+
+ API_USER_NOT_FOUND(123, "User not found", "http://example.com/123");
+ @Getter
+ private Integer errorCode;
+ @Getter
+ private String errorMessage;
+ @Getter
+ private String referenceUrl;
+
+ ErrorDetails(Integer errorCode, String errorMessage, String referenceUrl) {
+ this.errorCode = errorCode;
+ this.errorMessage = errorMessage;
+ this.referenceUrl = referenceUrl;
+ }
+}
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/User.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/User.java
new file mode 100644
index 0000000000..218b1ddad5
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/User.java
@@ -0,0 +1,20 @@
+package com.baeldung.spring.reactive.customexception.model;
+
+import java.time.LocalDate;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class User {
+ private Long id;
+ private String firstName;
+ private String lastName;
+ private String email;
+ private LocalDate dateOfBirth;
+}
diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/test/java/com/baeldung/spring/reactive/customexception/controller/UserControllerUnitTest.java b/spring-reactive-modules/spring-reactive-exceptions/src/test/java/com/baeldung/spring/reactive/customexception/controller/UserControllerUnitTest.java
new file mode 100644
index 0000000000..150d0b08a6
--- /dev/null
+++ b/spring-reactive-modules/spring-reactive-exceptions/src/test/java/com/baeldung/spring/reactive/customexception/controller/UserControllerUnitTest.java
@@ -0,0 +1,84 @@
+package com.baeldung.spring.reactive.customexception.controller;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.ProblemDetail;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+import com.baeldung.spring.reactive.customexception.exception.GlobalExceptionHandler;
+import com.baeldung.spring.reactive.customexception.model.ErrorDetails;
+
+@SpringBootTest(webEnvironment = RANDOM_PORT)
+class UserControllerUnitTest {
+
+ @Autowired
+ private WebTestClient webClient;
+
+ @InjectMocks
+ private UserController userController;
+
+ @Autowired
+ private GlobalExceptionHandler globalExceptionHandler;
+
+ @BeforeEach
+ void setupTests() {
+ // Given
+ webClient = WebTestClient.bindToController(userController)
+ .controllerAdvice(globalExceptionHandler)
+ .configureClient()
+ .build();
+ }
+
+ @Test
+ void givenGetUserV1Endpoint_whenHitWithInvalidUser_thenResponseShouldContainProblemDetailWithCustomAttributes() throws Exception {
+ // When
+ ProblemDetail problemDetail = webClient.get()
+ .uri("/v1/users/123")
+ .exchange()
+ .expectStatus()
+ .isNotFound()
+ .expectBody(ProblemDetail.class)
+ .returnResult()
+ .getResponseBody();
+ // Then
+ assertNotNull(problemDetail);
+ assertNotNull(problemDetail.getProperties().get("errors"));
+ List errors = (List) problemDetail.getProperties().get("errors");
+ assertEquals(ErrorDetails.API_USER_NOT_FOUND.getErrorCode().toString(),
+ errors.get(0).get("code"));
+ assertEquals(ErrorDetails.API_USER_NOT_FOUND.getErrorMessage().toString(),
+ errors.get(0).get("message"));
+ assertEquals(ErrorDetails.API_USER_NOT_FOUND.getReferenceUrl().toString(),
+ errors.get(0).get("reference"));
+ }
+
+ @Test
+ void givenGetUserV2Endpoint_whenHitWithInvalidUser_thenResponseShouldContainCustomException() throws Exception {
+ // When
+ String regex = "^\\{\\s*\"traceId\":\\s*\"[^\"]*\",\\s*\"timestamp\":\\d+\\.\\d+,\\s*\"status\":\\s*\"[^\"]*\",\\s*\"errors\":\\s*\\[\\s*\\{\\s*\"code\":\\s*\"[^\"]*\",\\s*\"message\":\\s*\"[^\"]*\",\\s*\"reference\":\\s*\"[^\"]*\"\\s*\\}\\s*\\]\\s*\\}$";
+ Pattern errorResponseSampleFormat = Pattern.compile(regex);
+ String errorResponse = webClient.get()
+ .uri("/v2/users/123")
+ .exchange()
+ .expectStatus()
+ .isNotFound()
+ .expectBody(String.class)
+ .returnResult()
+ .getResponseBody();
+ // Then
+ assertTrue(errorResponseSampleFormat.matcher(errorResponse).matches());
+ }
+
+}