In Episode 2 we poured the foundation — the project, every dependency, and all the configuration in one place. Before we build a single feature on top of it, we build the part that most tutorials skip entirely: error handling.
Episode 3 is about making every failure look the same. When something goes wrong in an API — a bad password, a duplicate email, an expired token — the client should get back one predictable shape, with the right HTTP status and a stable error code it can actually program against. No stack traces leaking to users. No endpoint returning 200 OK with an error buried inside. Today we build that backbone: a response envelope, a set of typed exceptions, and one handler that translates all of them.
What you’ll learn in Episode 3
This episode is the first real code of the series — and it’s deliberately unglamorous, because a consistent error contract is what makes everything after it pleasant to build. By the end you’ll understand:
- Why professional APIs build error handling before features
- The single response envelope every endpoint returns — success or failure
- Why typed exceptions beat a sea of generic
RuntimeExceptions - How
@RestControllerAdviceturns any exception into a clean, consistent response - How to map each failure to the correct HTTP status and a stable error code
- Two real bugs — and how to catch them before they reach production
Chapters
(Timings track the video — nudge them if your final edit shifted.)
- 0:00 — Why exceptions come first
- 0:54 — Exceptions Implementation -
The 7 exception classes - 6:20 — The
GlobalExceptionHandler - Part 2 - 10:57 — ApiResponse — the response envelope
- 18:21 — The
GlobalExceptionHandler - Part 2 - 27:08 — Recap and what’s next (Episode 4: DTOs)
Why error handling comes before features
It’s tempting to jump straight to registration and login. But if you build five endpoints first and bolt on error handling later, you end up with five different ways of failing: one endpoint throws a 500 with a stack trace, another returns null, a third sends back 200 OK with {"error": "..."} inside. Your frontend then needs custom code to interpret each one.
Build the contract first, and every feature you write afterwards inherits it for free. That’s the whole idea of this episode: decide once how success and failure look, then never think about it again.
The response envelope: ApiResponse
Every response this API sends — whether it succeeds or fails — travels inside one wrapper called ApiResponse. It carries a small, predictable set of fields: a success flag, a human-readable message, the payload (present on success), a stable error code (present on failure), and a timestamp.
The point is consistency: the client can look at success first, and always know where to find the data or the error. In this episode we build exactly as much of ApiResponse as the error handling needs — the failure shape and the error(...) path. We come back to it properly in Episode 4, alongside the request DTOs, to cover the generic payload and the success factories in full.
Why typed exceptions
The lazy approach is to throw new RuntimeException("user not found") everywhere and let it bubble up. It works, but it throws away information. “User not found” and “email already registered” and “account locked” are completely different situations that deserve completely different HTTP statuses — 404, 409, 423 — and different error codes the client can branch on.
So instead of one generic exception, we create purpose-built exception types. Each one names a specific failure and carries the HTTP status and error code that describes it. Throwing becomes expressive: throw new ResourceNotFoundException(...) says exactly what happened, and the handler already knows how to translate it.
The seven exception classes
The exception package holds seven typed exceptions, one for each failure mode this auth system actually has — think along the lines of: a resource that doesn’t exist, a duplicate that conflicts with something already there, invalid credentials, an invalid or expired token, a locked account, too many requests, and a validation failure. Each carries its own status and stable error code.
Alongside them lives the class that ties everything together — the GlobalExceptionHandler — giving eight classes in the package total. (The exact names and error-code strings are all in the repo; clone it to see the full set.)
insta-auth-service on GitHub and open the exception package to follow the code in this episode line for line.
The GlobalExceptionHandler
Here’s where it all comes together. @RestControllerAdvice lets you write one class that intercepts exceptions from every controller in the application. Instead of a try-catch in every method, you have a single translation layer.
Each handler method catches a specific exception, reads the status and error code it carries, and returns a clean ApiResponse with the correct HTTP status. The handler covers three kinds of failure:
- Our typed exceptions — each mapped to its intended status and code.
- Validation errors — when a request fails its validation rules, Spring throws
MethodArgumentNotValidException, which we turn into a cleanVALIDATION_ERRORresponse. (Those rules arrive in Episode 4.) - Everything else — a catch-all so no unexpected exception ever reaches the client raw.
The result: one place that owns how this API fails, and a guarantee that no controller can leak an ugly error by accident.
Two bugs, fixed on camera
Writing an exception handler is exactly the kind of code where subtle mistakes hide — so we leave two real ones in and fix them on screen, because spotting them is more instructive than never seeing them.
Bug #1: the wrong AccessDeniedException
There are two classes named AccessDeniedException on the classpath: one from java.nio.file (a file-system error) and the one we actually want, org.springframework.security.access.AccessDeniedException. The IDE’s auto-import happily picked the wrong one. The result: the handler looked correct, compiled fine, but never actually caught Spring Security’s access-denied event — real 403s were falling through to the catch-all. The fix is a one-line import change, and it’s a fantastic reminder to always check which class your IDE imported.
Bug #2: leaking internals with ex.getMessage()
The catch-all handler returned ex.getMessage() straight to the client. That feels helpful, but raw exception messages can expose internal details — SQL fragments, class names, file paths — that are gold to an attacker and gibberish to a user. The fix: send the client a safe, generic message, and log the real exception detail on the server where only you can see it. This is a small change with real security weight.
What we have now
At the end of Episode 3 the project has two new packages: dto (holding ApiResponse) and exception (the seven types plus the handler). There are still no controllers, no services, and no entities — and we still haven’t run the app, because there’s nothing to exercise yet. What we do have is a complete, consistent error contract that every feature from here on will use without a second thought.
What’s next: Episode 4 — DTOs
Next we build the request and response objects — and come back to ApiResponse properly, with its generic payload and success factories. We’ll add validation rules to the request DTOs, which is exactly what triggers the VALIDATION_ERROR handler we just wrote. New episodes drop every Monday and Thursday.
Frequently asked questions
What’s the difference between @ControllerAdvice and @RestControllerAdvice?
@RestControllerAdvice is @ControllerAdvice plus @ResponseBody — it automatically serializes the value your handler returns into the response body (JSON), which is exactly what you want for a REST API. Use it when your advice methods return objects rather than view names.
Why not just return null or throw a generic exception?
Returning null forces every caller to guess what went wrong, and a single generic exception collapses genuinely different failures into one indistinct 500. Typed exceptions preserve the meaning of each failure, so the handler can assign the right HTTP status and a stable error code the frontend can branch on.
How do I stop exception messages from leaking sensitive information?
Never send a raw ex.getMessage() to the client from your catch-all. Return a fixed, generic message for unexpected errors and log the real detail server-side. Your known, typed exceptions can carry safe, user-facing messages by design.
Why build error handling before any features?
Because it’s a contract, and contracts are cheapest to set at the start. Define the success and failure shape once, and every endpoint you build afterwards is consistent for free — instead of retrofitting five different error styles later.
Which HTTP status should a locked account or rate-limit return?
A locked account fits 423 Locked and rate limiting fits 429 Too Many Requests; a missing resource is 404, a conflict like a duplicate email is 409, and bad credentials are 401. Typed exceptions let you attach each of these precisely instead of defaulting everything to 500.
Watch Episode 3 and build along
- ▶️ Watch the full walkthrough above and subscribe for the Monday/Thursday episodes.
- ⭐ Star the GitHub repo — the
exceptionpackage has everything from this episode. - 🎓 Want the deeper version with OAuth2, MFA, and AWS deployment? Check out the full Udemy course.
- 💬 Want a 1:1 code review, architecture help, or career guidance? Book a session on Topmate.
New to the series? Start with Episode 1: The Architecture and Episode 2: Foundation. And drop a comment on the video telling me where you’re watching from — I reply to every one in the first 48 hours. See you in Episode 4.
Comments
Post a Comment