JPA exception handling: two developer strategies

When repository calls fail, JPA exceptions may be ungracefully thrown, causing your app to return HTTP 500 codes, and fail to appropriately respond to the error. Your app log most likely has the real error code (like an ORA- code) buried in a stack trace, but no action was taken on that error.

However, wrapping every repository call in try/catch boilerplate defeats the point of using of repositories to make our code more readable.

In this article, we’ll learn two methods of handling errors from the repository layer:

  • @ControllerAdvice and @ExceptionHandler when the error occurs on a web path.
  • Aspects with Spring AOP (Aspect Oriented Programming) to implement cross-cutting, general purpose error logic.

If you’d like to jump to the source code, check out. these resources:

Choosing a data error to handle

Our sample schema defines a database check constraint on student.gpa:

gpa number(3,2) check (gpa between 0.00 and 4.00)

Any value inserted outside those bounds will throw an ORA-02290 error. This gives the sample a real database error to handle!

Extracting ORA-code errors from Spring exceptions

To get the actual ORA error code, we’ll need to unwrap the exception thrown by Spring (and Hibernate). This can be done recursively or iteratively, handled by the sample in OracleErrorExtractor:

    public static Optional<OracleDatabaseError> from(Throwable throwable) {
        Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
        return findSQLException(throwable, seen)
                .filter(sqlException -> sqlException.getErrorCode() > 0)
                .map(sqlException -> OracleDatabaseError.fromErrorCode(
                        sqlException.getErrorCode(),
                        sqlException.getMessage()
                ));
    }

    private static Optional<SQLException> findSQLException(Throwable throwable, Set<Throwable> seen) {
        if (throwable == null || !seen.add(throwable)) {
            return Optional.empty();
        }

        if (throwable instanceof SQLException sqlException) {
            if (sqlException.getErrorCode() > 0) {
                return Optional.of(sqlException);
            }

            SQLException next = sqlException.getNextException();
            while (next != null && seen.add(next)) {
                if (next.getErrorCode() > 0) {
                    return Optional.of(next);
                }
                next = next.getNextException();
            }
        }

        return findSQLException(throwable.getCause(), seen);
    }
}

This returns a helpful OracleDatabaseError record that contains the error metadata:

public record OracleDatabaseError(
        int errorCode,
        String oraCode,
        String message
) {
    public static OracleDatabaseError fromErrorCode(int errorCode, String message) {
        return new OracleDatabaseError(
                errorCode,
                "ORA-%05d".formatted(errorCode),
                message
        );
    }
}

JPA exception handling: ControllerAdvice and ExceptionHandler

The REST path starts with a basic Controller class. The controller accepts a POST /students request to create a record, and doesn’t need to know about error handling:

@PostMapping("/students")
@ResponseStatus(HttpStatus.CREATED)
public Student createStudent(@RequestBody Student student) {
    student.setId(null);
    return studentService.createStudent(student);
}

Exception logic lives in a centralized handler. This handler can catch and handle exceptions as needed:

@ControllerAdvice
public class StudentExceptionHandler {
    @ExceptionHandler(OracleJpaException.class)
    public ResponseEntity<OracleErrorResponse> handleOracleJpaException(OracleJpaException exception) {
        OracleDatabaseError error = exception.getOracleError();
        HttpStatus status = error.errorCode() == 2290
                ? HttpStatus.BAD_REQUEST
                : HttpStatus.INTERNAL_SERVER_ERROR;

        OracleErrorResponse response = new OracleErrorResponse(
                error.oraCode(),
                "Oracle AI Database rejected the JPA operation.",
                error.message()
        );
        return ResponseEntity.status(status).body(response);
    }
}

Spring MVC’s controller advice support lets @ExceptionHandler methods apply beyond one controller when they are declared in a @ControllerAdvice class. That makes the shape useful for a shared API error contract: choose the status code, choose the response body, and keep those choices out of individual endpoints.

In the sample, ORA-02290 maps to 400 Bad Request because the client sent a GPA outside the schema’s allowed range. Unknown Oracle AI Database errors fall back to 500 Internal Server Error.

That fallback is not the end state for a production app. It is the honest default for a sample. In a real service, you would add mappings for the database errors your API can explain safely, add logging or metrics, and avoid returning raw database detail if it would expose internal schema information.

JPA exception: example mapping HTTP responses to exception handlers

JPA exception handling: Aspects and Spring AOP

What if your JPA code doesn’t live on a request path? Aspects cut across multiple types and objects to provide common wrapping logic. With AOP, the service code stays boring, and JPA exception handling is stored in on place.

JPA exception: using Spring AOP pointcuts to coordinate error handlers.

The AOP example lives in OracleExceptionAspect, wrpaping the com.example.exceptionhandling package:

@Around("within(com.example.exceptionhandling..*)")
public Object translateOracleJpaExceptions(ProceedingJoinPoint joinPoint) throws Throwable {
    try {
        return joinPoint.proceed();
    } catch (DataAccessException exception) {
        throw OracleErrorExtractor.from(exception)
                .map(oracleError -> new OracleJpaException(oracleError, exception))
                .orElseThrow(() -> exception);
    }
}

In this example, we extract the error and throw a typed exception. However, you can use this pointcut to run any specific error handling logic you’d like: publishing an event, logging a message, or attempting reconciliation of the specific error.

In a real application, I’d wrap a package/methods that represent app use cases for aware exception handling: A broad aspect can hide failures in places where a local decision would be clearer.

Try it out with Testcontainers

From the spring-jpa project directory, run the OracleDataAccessExceptionHandlingTest with Maven:

mvn test -Dtest=OracleDataAccessExceptionHandlingTest

The service-level test checks the AOP translation:

assertThatThrownBy(() -> studentService.createStudent(invalidGpaStudent()))
        .isInstanceOf(OracleJpaException.class)
        .extracting(exception -> ((OracleJpaException) exception).getOracleError())
        .satisfies(error -> {
            assertThat(error.errorCode()).isEqualTo(2290);
            assertThat(error.oraCode()).isEqualTo("ORA-02290");
        });

The web-level test checks the controller advice response:

mockMvc.perform(post("/students")
                .contentType(MediaType.APPLICATION_JSON)
                .content(new JsonMapper().writeValueAsString(invalidGpaStudent())))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.code", equalTo("ORA-02290")))
        .andExpect(jsonPath("$.message", equalTo("Oracle AI Database rejected the JPA operation.")))
        .andExpect(jsonPath("$.detail", containsString("ORA-02290")));

Where to use either approach

  • Use AOP for exception translation when you want one policy around a set of application operations. it’s useful when the caller might not be REST, or you want to encapsulate a specific code path.
  • Use @ControllerAdvice and @ExceptionHandler for HTTP response decisions. That includes status codes, logging, response bodies, and API-level wording.

Resources

Leave a Reply

Discover more from andersswanson.dev

Subscribe now to keep reading and get access to the full archive.

Continue reading