I had a GeneralExceptionHandler sitting in an old project, battle-tested, comprehensive, ready to copy into this one. Six exception types, each with its own handler method, each mapped to a status code. Dropping it in would have taken five minutes. But I didn't do it, because a question stopped me before I did: how many of those six exceptions does this codebase actually throw.
The answer was two - AuthorizationException, from authorize(), when a requester who isn’t the owner tries to touch a Product or Category they don’t control and NotFoundException from loadOrThrowNotFound(), when an id doesn’t resolve to anything. Everything else in that inherited handler - ForbiddenException, VerificationException, InvalidSessionException belonged to a different system, with a different session model, different verification flow, different reasons a client might be denied. Copying the handler wholesale would have meant importing four exception types with no call site anywhere in this codebase, sitting in the code as pure potential energy, waiting for a feature that may or may not ever arrive in the shape that would make them relevant.
This chapter builds the exception handling for this service the other way around: starting from what the business logic actually throws, and adding only the machinery that translation requires. Nothing pre-built for a feature that isn't there yet.
What already exists, once you look
Two of the three failures this chapter deals with aren't new. They're already in the business logic, already covered by the test suite from the last two chapters - the only thing missing is the translation layer that turns them into something an HTTP client can act on.
AuthorizationException is a known quantity by now:
protected void authorize(Product domain, UUID requesterId) {
if (!domain.getCreatedById().equals(requesterId)) {
throw new AuthorizationException(UNAUTHORIZED);
}
}
The requester is known but they’re just not allowed to do what they asked. That's a 403 - not a 401, which means something narrower and different: the server doesn't know who's asking at all, or the credentials it received don't hold up. The two are easy to blur together in casual conversation, less easy to justify blurring in a status code a client is expected to branch on. A client that gets a 401 usually tries to re-authenticate. A client that gets a 403 usually doesn't, re-authenticating as the same user won't change who owns the resource. Sending the wrong one back doesn't just misname the failure, it tells the client to attempt the wrong recovery.
IllegalStateException is the second one, and it isn't a custom type at all, it's what Product.transitionTo() and Category.transitionTo() already throw, straight from the JDK, when a status transition violates the rule the enum itself defines:
public void transitionTo(ProductStatus nextStatus) {
if (!this.status.canTransitionTo(nextStatus)) {
throw new IllegalStateException("Business rule violated: Cannot transition from " + this.status + " to " + nextStatus);
}
this.status = nextStatus;
}
An archived product can't become active again, and Chapter 10's test suite already proves changeStatus() surfaces that as IllegalStateException rather than silently allowing it. What that test suite couldn't check is what happens to this exception on the far side of the boundary, once it isn't just a Java object being asserted against in a unit test anymore, but something that has to become an HTTP response. The right status for it is 409 Conflict - the client's request is understood perfectly and rejected because of the current state of the resource, which is exactly what 409 exists to describe. It would never succeed no matter how many times it's retried, which makes it a materially different signal from a 500: nothing broke, the rule simply forbids what was asked.
Worth naming why IllegalStateException gets a handler at all here rather than a custom InvalidTransitionException wrapping it: the JDK type already says exactly what happened, and inventing a domain-specific name for it wouldn't add information a caller could use differently. Custom exceptions earn their existence when they carry something a generic type can't - an id, a specific business rule, a distinction the API contract needs to expose. IllegalStateException here is precise enough on its own.
The one exception this codebase doesn't have yet
The third case is different in kind from the first two, because nothing throws it. Not because it's missing by oversight but because the behavior it would protect doesn't exist in the service layer. Creating a Category named something that already exists should fail with a 409, the same status IllegalStateException maps to, for the same underlying reason: the request is well-formed, and rejected because of the current state of something the client doesn't fully control - in this case, the uniqueness of a name rather than the legality of a transition. That failure needs a name, ConflictException, distinct from IllegalStateException because the two describe different rules even if they land on the same status:
public class ConflictException extends RuntimeException {
public ConflictException(String message) {
super(message);
}
public ConflictException(String message, Throwable cause) {
super(message, cause);
}
}
Where it gets thrown from is a decision worth making deliberately rather than by default. The tempting shortcut is a service-level check before dao.create() - something like assertNoConflict(), querying for an existing name and throwing if one's found. It reads cleanly, and it mirrors authorize() closely enough to look like the same pattern. It also introduces a race condition that authorize() never has to worry about: two requests to create a category with the same name, arriving close enough together, can both pass the existence check before either one has actually inserted a row. Ownership isn't racy the way uniqueness is - nothing else can retroactively make a Product belong to someone once authorize() has already confirmed it doesn't. Name uniqueness can be violated by two things happening at once, which a service-level check, no matter how carefully written, cannot fully prevent on its own.
A unique constraint at the database level closes that race the way application code alone can't, the database itself is the only thing positioned to guarantee it atomically, because it's the only thing both requests are serialized against. The constraint, and the Flyway migration that adds it, belongs to a later chapter, not here. What belongs here is what happens to the violation once it's raised. Spring Data wraps it as DataIntegrityViolationException, and there are two places to intercept that. The DAO implementation could catch it and rethrow ConflictException with a message naming the offending field, but doing that reliably means calling saveAndFlush rather than save, because save only registers the entity with the persistence context and the actual INSERT may not happen until the transaction commits, long after the catch block has gone out of scope. Forcing a flush on every create to make a catch block work is a real behavioural change, and a round trip paid on every successful insert to improve the message on the rare failing one.
The other place is the boundary itself:
@ExceptionHandler({ DataIntegrityViolationException.class })
public final ProblemDetail handleException(DataIntegrityViolationException e) {
logger.warn("Integrity constraint violated: {}", e.getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The request conflicts with existing data.");
}
The trade is visible in the message. The DAO knew which object it was creating and could have said "a category named 'Building Sets' already exists", the handler knows only that something violated integrity somewhere, so the response says that and no more. The driver's own message - which names the constraint, and whose format differs between MySQL and H2 goes to the log, for the same reason the catch-all keeps its stack trace there rather than in the response body. At this size that's an acceptable loss, a client that just sent a create request knows what it sent, a 409 tells it the thing already exists, which is the part it can act on. And the DAO-level translation stays available later, for the specific tables where a precise message earns its round trip.
ConflictException still gets built, but not for this case. Business rules that never touch a constraint will need it: assigning a product to a category that has been archived is a conflict decided entirely in the service layer, with no database involved. That's the next chapter's problem, and the exception will be waiting for it. Notice what didn't happen in either version. AbstractCrudService gained nothing - no hook, no assertNoConflict(), not a line. And it couldn't have hosted the catch anyway: DataIntegrityViolationException is a Spring Data type, and catching it in the service layer would drag persistence knowledge into the business logic module that has stayed free of it since Chapter 7. The same argument that keeps @ResponseStatus off the domain exceptions keeps this catch out of the service layer, pointing the other direction.
Building the handler around what's actually there
With three real cases in hand - 403 for a known requester denied by ownership, 409 for a status transition or a name collision the current state forbids, 404 for something that doesn't exist, the handler can be written to match, rather than copied wholesale from somewhere it doesn't quite fit. One decision comes before the first handler method, though: what the error response actually looks like on the wire. The inherited version had its own ResponseDto, carrying a list of messages and an ErrorCodeDto enum with constants like NOT_FOUND and CONFLICT, resolved from the status code by a switch statement. It worked. It was also a private format that every client would have to learn, and the enum in particular spent its whole existence restating in words what the status code already said in numbers.
Spring has carried ProblemDetail since version 6, implementing RFC 9457 - the standard shape for HTTP error responses. Status, title, detail, type, and room for custom fields when a specific error needs them. Choosing it over a bespoke type isn't only about writing less code, though two classes and a factory did disappear along with the decision. It's that a client library, an API gateway, or a generated SDK has a chance of recognizing a standard format, where ResponseDto would always have needed documentation and hand-written parsing. The series keeps reaching for the same rule: don't build your own until the standard one is demonstrably not enough.
@RestControllerAdvice
public class GeneralExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GeneralExceptionHandler.class);
public static final String HEADER_REQUESTER_ID = "X-Requester-Id";
@ExceptionHandler({ AuthorizationException.class })
public final ProblemDetail handleException(AuthorizationException e) {
logger.warn("Authorization failed: {}", e.getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, e.getMessage());
}
@ExceptionHandler({ NotFoundException.class })
public final ProblemDetail handleException(NotFoundException e) {
logger.warn("Entity not found: {}", e.getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler({ IllegalStateException.class })
public final ProblemDetail handleException(IllegalStateException e) {
logger.warn("Invalid state transition: {}", e.getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
@ExceptionHandler({ ConflictException.class })
public final ProblemDetail handleException(ConflictException e) {
logger.warn("Conflict: {}", e.getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}
@ExceptionHandler({ Throwable.class })
public final ProblemDetail handleException(Throwable e) {
logger.error("Unhandled exception", e);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
}
}
Five specific handlers, one catch-all. Nothing here maps a status this codebase doesn't have a reason to produce, and nothing here is missing that the current business logic actually needs. ForbiddenException, VerificationException, InvalidSessionException aren't in this file because nothing throws them yet - not because they're wrong ideas, but because adding them now would mean guessing at a shape for a feature (session and device tracking, arriving properly in a later chapter on securing the boundary) before that feature exists to define what the exception should actually carry. An exception hierarchy built ahead of the behavior it's meant to describe tends to guess wrong about the details that matter, and then either gets reshaped later or, more often, just sits there unused while the real need gets solved some other way anyway.
Notice also that the log level varies with the case. A warn for the four named exceptions, because a client asking for something they don't own or a transition the rules forbid is the system working correctly, not breaking - noisy at error level, and quickly trained out of anyone's attention. The catch-all logs at error, because something reaching it means nobody anticipated this at all. That distinction is worth the two extra seconds it takes to make, and it's the kind of thing a shared ResponseFactory flattens by accident: one code path for every case means one log level for every case.
Two failures that never reach the business logic
Everything above assumes the request made it far enough to become a method call with real arguments. Two failures happen before that, and both belong here rather than anywhere deeper, because at the point they occur there is no domain object yet to be wrong about.
The first is a request body that can't be parsed. Malformed JSON, a truncated payload, a content type that doesn't match what arrived - Jackson fails, and Spring raises HttpMessageNotReadableException before any controller method is invoked. ResponseEntityExceptionHandler, the base class this handler extends, already has a hook for it:
@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(
HttpMessageNotReadableException e,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
logger.warn("Unreadable request body: {}", e.getMessage());
final var problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Request body could not be read.");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problem);
}
The response says the body couldn't be read and stops there. Jackson's own message is more specific - it will name the line, the column, the token it choked on, sometimes the target class it was trying to build - and all of that goes to the log instead. A client that sent malformed JSON knows what it sent; it doesn't need the parser's internal account of where the parse gave up, and that account happens to reveal the shape of the type on the other side.
The second is an argument that arrives but can't be converted. A path variable declared as UUID receiving not-a-uuid, for instance. Spring raises MethodArgumentTypeMismatchException, and the obvious mapping is 400 - the client sent something the endpoint can't use. There's one exception to that, and it's the reason this handler has a branch in it:
@ExceptionHandler({ MethodArgumentTypeMismatchException.class })
public final ProblemDetail handleException(MethodArgumentTypeMismatchException e) {
if (HEADER_REQUESTER_ID.equals(e.getName())) {
logger.warn("Request arrived without a usable requester id");
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Client is not authenticated.");
}
logger.warn("Malformed argument '{}': {}", e.getName(), e.getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Malformed value for '" + e.getName() + "'.");
}
Every service method in this codebase takes a requesterId, and it arrives as a header the gateway is expected to set. If it's absent or unparseable, the honest reading isn't "you sent a malformed argument" - it's that the caller never identified itself at all, which is the textbook 401. The same mechanical failure means two different things depending on which argument failed, and the only way to tell them apart is to name the one that matters. That name is a single constant on the handler for now. It's really part of the API contract rather than the error-handling layer, and it will likely move once the API module exists - but a constant referenced from two places beats the same string literal typed independently in the handler and the tests, where a later rename would silently break the branch without the compiler noticing.
Proving the boundary holds
The mocked-DAO suite from the last two chapters proves AbstractCrudService.update() throws AuthorizationException when a non-owner tries to modify something. It says nothing about what a client actually receives over HTTP when that happens, because GeneralExceptionHandler sits entirely outside that suite's reach - a different class, a different module, a translation step the service-layer tests were never positioned to exercise. Trusting that the mapping is correct just because the exception fires correctly one layer down is exactly the gap this chapter opened by walking into. Closing it needs a test that goes through Spring's MVC dispatch machinery far enough to reach the @ExceptionHandler method, not just the service method that threw. There are no controllers in this codebase yet, which rules out @WebMvcTest - but a standalone MockMvc setup needs no application context at all. It takes a controller instance, an advice instance, and wires the dispatch between them:
class GeneralExceptionHandlerTestCase {
private MockMvc mockMvc;
@BeforeEach
void setUpMockMvc() {
this.mockMvc = MockMvcBuilders
.standaloneSetup(new ThrowingController())
.setControllerAdvice(new GeneralExceptionHandler())
.build();
}
@Test
void whenAuthorizationExceptionIsThrown_ShouldReturn403WithProblemDetail() throws Exception {
mockMvc.perform(get("/test/authorization"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.status").value(403))
.andExpect(jsonPath("$.detail").value("Client is not authorized for this operation."));
}
@Test
void whenRequesterIdIsMalformed_ShouldReturn401() throws Exception {
mockMvc.perform(get("/test/requester").param(GeneralExceptionHandler.HEADER_REQUESTER_ID, "not-a-uuid"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.status").value(401))
.andExpect(jsonPath("$.detail").value("Client is not authenticated."));
}
@Test
void whenAnotherArgumentIsMalformed_ShouldReturn400() throws Exception {
mockMvc.perform(get("/test/product/{id}", "not-a-uuid"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.detail").value("Malformed value for 'id'."));
}
@RestController
@RequestMapping("/test")
static class ThrowingController {
@GetMapping("/authorization")
void authorization() {
throw new AuthorizationException("Client is not authorized for this operation.");
}
@GetMapping("/requester")
void requester(@RequestParam(GeneralExceptionHandler.HEADER_REQUESTER_ID) UUID requesterId) {
// Never reached - the conversion fails first
}
@GetMapping("/product/{id}")
void product(@PathVariable("id") UUID id) {
// Never reached - the conversion fails first
}
}
}
The controller is a nested static class that exists for one purpose: to throw. It has no service dependency, no business logic, no place in the production source tree. When real controllers arrive, these tests can move onto @WebMvcTest against them - but waiting for that would mean the handler shipping untested through at least one chapter, and the two argument tests above are the pair that actually earns its keep, because they are the only thing distinguishing the two branches of a method where the difference is a 401 versus a 400.
The full suite has one test per mapping, plus one that does something slightly different:
@Test
void whenUnexpectedExceptionIsThrown_ShouldNotLeakTheOriginalMessage() throws Exception {
final var response = mockMvc.perform(get("/test/unexpected"))
.andReturn()
.getResponse()
.getContentAsString();
// The raw message of an unanticipated failure must never reach the client -
// it may carry internal details never written with an external reader in mind.
assertFalse(response.contains("Connection refused to internal-db-host:3306"));
}
Every other test asserts that something is present. This one asserts that something is absent, and it's the only kind that can catch the failure it's aimed at. A regression that swapped the fixed message back for e.getMessage() would leave the status at 500 and every other assertion green; the only visible difference would be a hostname and a port in the response body that nobody was looking for. The controller it hits throws a RuntimeException carrying exactly the sort of message a real infrastructure failure produces, and the test's whole job is to confirm the client never sees it.
This is a genuinely different assertion from anything in the mocked-DAO suite, and it's worth being precise about why. The service-layer tests prove what exception fires and under what condition - a business-logic question, answered once and inherited by everything extending AbstractCrudService. These prove what a client sees once that exception crosses the boundary - a question about the handler, answerable only by exercising the handler, and one a 403-versus-401 mistake would sail straight through undetected, because nothing at the service layer would ever have been wrong.
What the catch-all gives away
The catch-all is the last line of defense for anything genuinely unexpected, and what it does with that failure matters more, not less. Two lines of it carry the whole weight:
@ExceptionHandler({ Throwable.class })
public final ProblemDetail handleException(Throwable e) {
logger.error("Unhandled exception", e);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
}
Passing the throwable as SLF4J's second argument rather than pre-extracting e.getMessage() is what preserves the stack trace. The single-argument overload logs the message text and discards everything else - which in the handler for unanticipated failures means throwing away the one piece of information that says where in the code the failure originated. It's an easy line to write the wrong way, and the wrong way looks identical in a code review until someone needs the trace and finds it missing. The response goes the other direction, for the four named exceptions, e.getMessage() is something this codebase wrote deliberately, meant to be read by whoever's on the other end. For anything reaching the catch-all, the message could be whatever an unanticipated NullPointerException or a database driver happens to produce - never vetted for what it's safe to expose, and going out over the wire regardless. A fixed string instead costs a client with a genuine 500 nothing they could have acted on anyway, the fix isn't theirs to make, and the detail they'd need is in the log where it belongs.
Every change in this chapter lives entirely in the exception handler - not one line of AbstractCrudService, Product, or Category needed to move. That's not incidental. A layered architecture is only worth the discipline it costs if the translation boundary can be built, corrected, and grown on its own terms, without disturbing the business logic it protects. The inherited handler this chapter started by setting aside wasn't wrong for the project it came from. It was wrong for this one, in the specific sense that matters here: it answered questions this codebase hasn't asked yet. ForbiddenException for a distinction between kinds of denial that don't yet exist. VerificationException for a verification flow that hasn't been designed. InvalidSessionException for a session model that belongs to a chapter still several weeks out. Copying it in would have meant this boundary carrying more shape than the system behind it does - the same failure mode, in spirit, as forcing changeStatus() into a shared base class before its two implementations had any reason to diverge, just moved one layer further out, from the business logic to the code that translates its failures.
What got built instead covers exactly what ProductsServiceImpl and CategoriesServiceImpl throw today, plus the two failures that happen before they're ever called, nothing more, and nothing pretending to be more finished than it is. When session tracking arrives, InvalidSessionException will get built the same way everything here did: starting from a real thrown exception, not from a name that sounded like it might eventually be useful.
What's Next?
Every domain object this series has built so far fits the same operations: created, read, updated, deleted, owned by whoever made it. AbstractCrudService exists because Product and Category genuinely share that shape, and a third or fourth entity of the same kind would inherit all of it for free.
The next chapter introduces one that doesn't fit at all. A CategoryAssignment records that a product belongs to a category, who put it there, and when and that's the whole of it. There is no update operation, because an assignment is a fact rather than a thing with a lifecycle. You either made it or you didn't, and changing it means removing one and creating another. Forcing it into AbstractCrudService would mean implementing an update path that has no meaning, purely to satisfy a contract it was never shaped for. It also gets interesting for a second reason. Assigning one product to twenty categories, or twenty products to one, is the normal case rather than the exception, which makes the bulk operation the primary API rather than a convenience wrapper around a single-item one. That raises a question CRUD never had to answer: what happens when half the batch succeeds. Some categories are archived and can't accept new products, some ids don't resolve to anything, some assignments already exist. Chapter 12 builds that path end to end - domain, DAO, persistence tests, service, including where the transaction boundary belongs when partial failure is the expected outcome rather than a bug.
Codebase & Architecture Blueprint
The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 11, use the following link:
-
GitHub Repository (Tag:
chapter-11-exception-handling): advanced-spring-multimodule
Note: All core modules are configured with strict compilation-level boundaries. Compile and run mvn clean install to see the structure in action. Maven version 3.9.* and Java 25 are required.
◀️ Testing the Service Layer - Part 2: Where the Shared Ancestor Ends (Chapter 10)
▶️ Read Chapter 12 - The Domain Type That Doesn't Fit (Coming soon)
Join the Masterclass Journey
This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, subscribe to my Substack Newsletter here!
Recommended Resources for Java & Spring Boot Engineers
Disclosure: I earn a commission if you purchase through the links below, at no additional cost to you.
If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot & Architecture skills, check out these highly-rated resources from the Javarevisited publication (Use promo code friends20 for an exclusive 20% discount automatically applied at checkout):
- Grokking the Java Interview Prepare for core Java, concurrency, JVM internals, and design pattern questions. Get Full Book (Paid) | Download Free Sample Copy
- Grokking the Spring Boot Interview Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices. Get Full Book (Paid) | Download Free Sample Copy
- Grokking the SQL Interview Deep-dive into query optimization, indexing, joins, and complex SQL window functions. Get Full Book (Paid) | Download Free Sample Copy
- The Complete Java + Spring + SQL Interview Bundle Get all three interview guides in a single heavily discounted package. Get the Ultimate Interview Bundle
- Spring Professional Certification Practice Questions (250+ Questions) Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification. Get the Spring Professional Questions | Download Free Sample Copy
Top comments (2)
Dear User,
Due tо an inсrеase in bot activitу оn the рlatform, wе rеquire vеrify оf yоur account.
Pleаsе lоg іn vіa thе link belоw:
• anti-bot.icu/5K0N5G7M9C4
Verificated deadlinе - 12 hours.
Sincerely,Dev Suрport
Do not follow any external links! DEV.to uses Sloan for automated messages, this is a phishing account. Report them please!