<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vaishnavi Agrawal</title>
    <description>The latest articles on DEV Community by Vaishnavi Agrawal (@vaishnavi_agrawal_c7f7d72).</description>
    <link>https://dev.to/vaishnavi_agrawal_c7f7d72</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2873113%2F672248c7-30de-44cc-882f-d0db00666653.jpg</url>
      <title>DEV Community: Vaishnavi Agrawal</title>
      <link>https://dev.to/vaishnavi_agrawal_c7f7d72</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vaishnavi_agrawal_c7f7d72"/>
    <language>en</language>
    <item>
      <title>Your AI Feature Should Not Break Your Endpoint</title>
      <dc:creator>Vaishnavi Agrawal</dc:creator>
      <pubDate>Thu, 23 Jul 2026 18:08:02 +0000</pubDate>
      <link>https://dev.to/vaishnavi_agrawal_c7f7d72/your-ai-feature-should-not-break-your-endpoint-1o37</link>
      <guid>https://dev.to/vaishnavi_agrawal_c7f7d72/your-ai-feature-should-not-break-your-endpoint-1o37</guid>
      <description>&lt;p&gt;&lt;em&gt;Welcome back. This is the third article about my Purchase Decision API, after &lt;a href="https://vaishnaviagrawal1.substack.com/p/how-i-cleaned-up-error-handling-in" rel="noopener noreferrer"&gt;error handling&lt;/a&gt; and &lt;a href="https://vaishnaviagrawal1.substack.com/p/stop-bad-requests-at-the-door-with" rel="noopener noreferrer"&gt;validation&lt;/a&gt;. This week the API got an AI feature, and this piece is about the design decision that mattered most.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everyone is adding LLMs to their APIs right now. Almost nobody talks about what happens when the LLM is down.&lt;/p&gt;

&lt;p&gt;I just built a purchase decision API. You tell it what you want to buy, and it tells you BUY, WAIT, or SKIP, with a score out of 100 and a savings plan. On top of that sits an AI-written explanation in friendly, human language.&lt;/p&gt;

&lt;p&gt;The most important design decision I made was this: &lt;strong&gt;the AI cannot touch the verdict.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious design, and why I did not use it
&lt;/h2&gt;

&lt;p&gt;The tempting architecture is simple. Send the purchase details to an LLM and let it decide. One API call, no scoring logic to write, and the response already comes back in natural language.&lt;/p&gt;

&lt;p&gt;I did not do it, for three reasons.&lt;/p&gt;

&lt;p&gt;First, LLMs are not deterministic. The same purchase could get different verdicts on different days, and a decision API that changes its mind for no reason is not a decision API.&lt;/p&gt;

&lt;p&gt;Second, it couples my uptime to someone else's. If the model provider has an outage, my endpoint has an outage.&lt;/p&gt;

&lt;p&gt;Third, and this one settled it: a hallucinated verdict about someone's money is worse than no verdict at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A floor and a ceiling
&lt;/h2&gt;

&lt;p&gt;So I split the response into two layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CEILING   LLM explanation          (nice to have, can fail)
FLOOR     score, verdict, plan     (pure Java, never fails)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9yvnnn5hbdqblce6o44r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9yvnnn5hbdqblce6o44r.png" alt="Architecture diagram titled One request, two layers: a POST request always flows through the pure-Java floor (scoring engine, verdict and score, response), while the verdict also feeds an optional AI ceiling where the LLM call is wrapped in a catch that logs and degrades to a template." width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The floor is everything the endpoint guarantees. My scoring engine is deterministic: it takes disposable income, price ratio, purchase type, and usage frequency, and produces a 0 to 100 score. Plain Java, no AI anywhere in that path. Same inputs, same score, every time.&lt;/p&gt;

&lt;p&gt;The ceiling is everything that makes the response nicer but is allowed to fail. The AI explanation lives here, and only here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell the model the answer
&lt;/h2&gt;

&lt;p&gt;By the time the LLM is called, the verdict already exists. The model is not asked to decide anything. It is told the answer and asked only to explain it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s"&gt;"A user wants to buy '"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;itemName&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"' for ₹"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;". "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"Their monthly disposable income is ₹"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;disposableIncome&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;". "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"The affordability verdict is "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" with a score of "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/100. "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"Explain this verdict in 2-3 friendly, non-judgmental sentences. "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"Do not repeat the numbers back mechanically — give practical, warm advice."&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line matters more than it looks. Without it, the model just recites your inputs back as a sentence, and you have spent an API call turning numbers into slightly longer numbers.&lt;/p&gt;

&lt;p&gt;Notice what this structure buys you. Even if the model hallucinates, the worst it can produce is a badly worded explanation of a correct verdict. &lt;strong&gt;The blast radius of a bad AI response is prose, not money.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback
&lt;/h2&gt;

&lt;p&gt;The layers meet a second time at failure handling. The LLM call is wrapped so that any failure, whether a rate limit, a network error, or a malformed response, degrades to a template built from the same numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;generateExplanation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;printStackTrace&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// more on this line in a second&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;buildFallbackExplanation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;itemName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;disposableIncome&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here are the two outputs side by side, from the same request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9m52yv8n63pbr4ur6krf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9m52yv8n63pbr4ur6krf.png" alt="Side-by-side comparison of the same request: the warm AI explanation when the call succeeds, and the mechanical fallback template when anything fails, carrying identical information." width="799" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The AI version:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It looks like investing in those Sony headphones might not be the best choice right now, considering your monthly income and expenditures..."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fallback version:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Based on your finances, buying Sony headphones for ₹30000 scored 45/100, giving a verdict of WAIT. This is measured against your monthly disposable income of ₹27000."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Worse prose. Identical information. &lt;strong&gt;The user loses warmth, never the answer.&lt;/strong&gt; That is what graceful degradation actually means: the experience gets worse, the contract does not.&lt;/p&gt;

&lt;p&gt;And yes, &lt;code&gt;catch (Exception e)&lt;/code&gt; is normally a code smell. Here it is the point. This catch sits at the boundary of the optional layer, and there is no exception the AI layer can throw that should reach the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that made me respect logging
&lt;/h2&gt;

&lt;p&gt;My first version of this failed in a way I want to be honest about. Every response came back with the mechanical explanation, and I had no idea why. No errors, no crashes, nothing in the response to suggest a problem.&lt;/p&gt;

&lt;p&gt;The fallback was working too well. It was hiding its own trigger.&lt;/p&gt;

&lt;p&gt;Once I added logging, the cause turned out to be one missing character. I had written &lt;code&gt;"Bearer" + apiKey&lt;/code&gt;. No space after Bearer. The auth header was malformed, OpenAI rejected every single call, and the fallback fired every time, exactly as designed.&lt;/p&gt;

&lt;p&gt;That is the uncomfortable property of graceful degradation: &lt;strong&gt;a fallback that logs nothing is undebuggable.&lt;/strong&gt; The system looks fine from the outside while quietly running in degraded mode forever. The rule I took away is short: log the failure, degrade anyway. Users should never see the AI layer fail, but you always should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest notes
&lt;/h2&gt;

&lt;p&gt;A few things this design does not solve.&lt;/p&gt;

&lt;p&gt;The latency is still there when the AI is up. A synchronous LLM call adds real time to every response. For a low-traffic project that trade-off is acceptable; a high-traffic API would want the explanation generated asynchronously or cached, and that is not built here yet.&lt;/p&gt;

&lt;p&gt;The broad catch is only right at this one boundary. Inside the scoring engine I still want real exceptions with real handling, the kind I wrote about in the &lt;a href="https://vaishnaviagrawal1.substack.com/p/how-i-cleaned-up-error-handling-in" rel="noopener noreferrer"&gt;error handling article&lt;/a&gt;. Catching everything is a deliberate choice for the optional layer, not a general style.&lt;/p&gt;

&lt;p&gt;The fallback text is genuinely worse. Users notice the difference between warm advice and a template. An open question I have not answered yet: should the response admit degraded mode explicitly, with a field the client can read, or stay quiet about it?&lt;/p&gt;

&lt;p&gt;I am still learning this area. This design is simply what survived contact with my own bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;The endpoint worked before the AI feature existed, and it has to keep working when the AI fails. So the response is split in two: a deterministic floor in plain Java that always returns the score, the verdict, and the plan, and an optional AI ceiling that explains the verdict when the model is reachable and falls back to a template when it is not. The model is told the answer instead of being asked for one, so a hallucination can only ever ruin the wording. And the fallback logs every failure, because a silent fallback once hid a one-character bug from me for days.&lt;/p&gt;

&lt;p&gt;Let the AI reduce what your user has to read. Never let it decide what your system guarantees.&lt;/p&gt;

&lt;p&gt;If you were building this, which would you choose: a response that openly admits degraded mode with an explicit field, or a fallback the user never knows about? I would like to hear arguments for either side.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;P.S. If this was useful, subscribe. I write one piece like this every week.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>architecture</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Stop Bad Requests at the Door with Spring Validation</title>
      <dc:creator>Vaishnavi Agrawal</dc:creator>
      <pubDate>Thu, 16 Jul 2026 04:19:48 +0000</pubDate>
      <link>https://dev.to/vaishnavi_agrawal_c7f7d72/stop-bad-requests-at-the-door-with-spring-validation-2kcn</link>
      <guid>https://dev.to/vaishnavi_agrawal_c7f7d72/stop-bad-requests-at-the-door-with-spring-validation-2kcn</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F680yktdd69p7ihhl3876.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F680yktdd69p7ihhl3876.png" alt="Cover image: a code window shows a POST /api/auth/register request with an empty email and a weak password, each marked with a red validation error, above an HTTP 400 Bad Request response labeled stopped by the Valid annotation at checkpoint 2." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Welcome to my newsletter. Every week I take one backend topic apart and write down what clicked, with working code from something I actually built. This week: validating REST requests.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Until I added validation, nothing in my register endpoint stopped a blank email and an empty password from sailing straight through. The first thing to complain would have been the database, five layers too late and in an error message no client could use.&lt;/p&gt;

&lt;p&gt;For a while I handled this the obvious way: if-blocks at the top of the controller. It worked, but it looked exactly like my error handling before &lt;a href="https://vaishnaviagrawal1.substack.com/p/how-i-cleaned-up-error-handling-in" rel="noopener noreferrer"&gt;last week's refactor&lt;/a&gt;: the same checks copied into every endpoint, and controllers filling up with code that had nothing to do with their actual job.&lt;/p&gt;

&lt;p&gt;Fixing it properly forced me to understand something more useful than any annotation: what actually happens to a request between the raw JSON arriving and my method running. Once I could see that pipeline, I knew where every kind of "bad" belongs. That is what this article is really about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The journey of a bad request
&lt;/h2&gt;

&lt;p&gt;When a POST hits my register endpoint, my controller method is not the first stop. The request has to survive three checkpoints, and each one rejects a different kind of bad:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deserialization.&lt;/strong&gt; Jackson turns the raw JSON into my &lt;code&gt;RegisterRequest&lt;/code&gt; object. If the JSON is malformed, or a field cannot become its Java type (text where a number belongs), the journey ends here. Validation never runs, because there is no object to validate yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation.&lt;/strong&gt; The object exists; now &lt;code&gt;@Valid&lt;/code&gt; checks whether its contents follow my rules. Is the email shaped like an email? Is the password long enough? No database, no business context, just the data judging itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business rules.&lt;/strong&gt; Only now does my method run, and the service decides things data alone cannot: does this email already exist? That check needs the database, which is why it lives in the service layer and throws my &lt;code&gt;EmailAlreadyExistsException&lt;/code&gt; from last week, not a validation error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The distinction that clicked for me: &lt;strong&gt;validation answers "is this data well-formed?", business rules answer "is this data acceptable right now?"&lt;/strong&gt;. "Email must look like an email" is checkpoint 2. "Email must not be taken" is checkpoint 3. Put a check at the wrong checkpoint and you get code that either cannot do its job or does it in the wrong place.&lt;/p&gt;

&lt;p&gt;Each checkpoint also fails with a different exception, which matters later: checkpoint 1 throws &lt;code&gt;HttpMessageNotReadableException&lt;/code&gt;, checkpoint 2 throws &lt;code&gt;MethodArgumentNotValidException&lt;/code&gt;, checkpoint 3 throws whatever my service decides. Three kinds of bad, three signals.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftqi9qk9tmw765eqj6rfi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftqi9qk9tmw765eqj6rfi.png" alt="Flow diagram of the journey of a bad request: raw JSON passes checkpoint 1 where Jackson deserializes it, checkpoint 2 where the Valid annotation checks the DTO rules, and checkpoint 3 where the service checks business rules. Each checkpoint fails with its own exception, and all three failures route into one RestControllerAdvice desk that returns a single JSON ErrorResponse." width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one dependency everyone forgets
&lt;/h2&gt;

&lt;p&gt;Before any of checkpoint 2 works, the gotcha that costs people an hour: validation is not included in the web starter. Since Spring Boot 2.3, &lt;code&gt;spring-boot-starter-web&lt;/code&gt; ships without it, so you can write validation annotations all day and &lt;code&gt;@Valid&lt;/code&gt; will silently do nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-validation&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your validation "does not work", check this first. In Spring Boot 3 the annotations live in &lt;code&gt;jakarta.validation&lt;/code&gt;, not the &lt;code&gt;javax.validation&lt;/code&gt; you will still see in older tutorials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checkpoint 2 in practice: rules live on the DTO
&lt;/h2&gt;

&lt;p&gt;Bean Validation puts the rules on the data itself. My real &lt;code&gt;RegisterRequest&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Data&lt;/span&gt;
&lt;span class="nd"&gt;@AllArgsConstructor&lt;/span&gt;
&lt;span class="nd"&gt;@NoArgsConstructor&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RegisterRequest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@NotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Name is required"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Size&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Name cannot exceed 100 characters"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@NotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Email is required"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Email&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Email should be valid"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@NotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Password is required"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Size&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Password must be at least 8 characters"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Pattern&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;regexp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^(?=.*[0-9])(?=.*[A-Z])(?=.*[!@#$%^&amp;amp;*()_+\\-=\\[\\]{};':\"\\\\|,.&amp;lt;&amp;gt;/?]).*$"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Password must contain at least one number, one uppercase letter, and one symbol"&lt;/span&gt;
    &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules sit next to the fields they protect, the &lt;code&gt;message&lt;/code&gt; values speak to the API caller rather than to me, and the controller knows none of this exists. The regex is three lookaheads (a digit, an uppercase letter, a symbol); &lt;code&gt;@Size&lt;/code&gt; handles length separately, so each failure produces its own message.&lt;/p&gt;

&lt;p&gt;One subtlety worth knowing: &lt;code&gt;@NotBlank&lt;/code&gt; and &lt;code&gt;@NotNull&lt;/code&gt; are not interchangeable. A password of three spaces passes &lt;code&gt;@NotNull&lt;/code&gt; happily. &lt;code&gt;@NotBlank&lt;/code&gt; means "not null, and not just whitespace", which is a text-only concept. That difference caused my favorite bug of this refactor, but that story comes later.&lt;/p&gt;

&lt;p&gt;Turning the checkpoint on is one word in the controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/register"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AuthResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Valid&lt;/span&gt; &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;RegisterRequest&lt;/span&gt; &lt;span class="n"&gt;registerRequest&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;AuthResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registerRequest&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@Valid&lt;/code&gt; tells Spring to run the DTO's constraints after deserialization and before my method body. If anything fails, my method never runs, and the bad data never touches the service layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a failure actually contains
&lt;/h2&gt;

&lt;p&gt;When checkpoint 2 fails, Spring does not hand you a string. &lt;code&gt;MethodArgumentNotValidException&lt;/code&gt; carries a &lt;code&gt;BindingResult&lt;/code&gt;, which holds one &lt;code&gt;FieldError&lt;/code&gt; per broken rule: the field name, the rejected value, and the message from the annotation. And rules fail independently, even on the same field. A blank password breaks &lt;code&gt;@NotBlank&lt;/code&gt;, &lt;code&gt;@Size&lt;/code&gt;, and &lt;code&gt;@Pattern&lt;/code&gt; all at once, which is three FieldErrors from a single field. Validation reports every broken rule, not just the first one it finds.&lt;/p&gt;

&lt;p&gt;That structure is raw material. My job is to decide what shape the client sees, and I already made that decision last week: everything returns my &lt;code&gt;ErrorResponse&lt;/code&gt; class. So the handler mines the FieldErrors and folds them into that shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MethodArgumentNotValidException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleValidation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MethodArgumentNotValidException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBindingResult&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFieldErrors&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getField&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;": "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDefaultMessage&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;collect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Collectors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;joining&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;", "&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocalDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One new method in the same GlobalExceptionHandler, no new error system. The client gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email: Email is required, password: Password must be at least 8 characters"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-14T18:20:41"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same shape as a 404, same shape as a 409. A client that could parse my errors last week parses these without learning anything new.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checkpoint I forgot to handle
&lt;/h2&gt;

&lt;p&gt;Writing this article exposed a hole in my own setup. My handler covered checkpoint 2, and my custom exceptions covered checkpoint 3. But checkpoint 1 had nothing: no handler in the class mentioned &lt;code&gt;HttpMessageNotReadableException&lt;/code&gt;, which means malformed JSON had no guaranteed answer, let alone the right one.&lt;/p&gt;

&lt;p&gt;The right one is a 400. A 500 says "we broke"; malformed JSON means "you sent garbage". The fix is the same pattern a third time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpMessageNotReadableException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleUnreadable&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpMessageNotReadableException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Request body is missing or malformed"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocalDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now all three checkpoints report through the same desk, each with an honest status code. That is the real payoff of understanding the pipeline: when a new kind of failure shows up, you know exactly which checkpoint it belongs to and where its handler goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two mistakes that taught me the rules
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mistake one: &lt;code&gt;@NotBlank&lt;/code&gt; on a number.&lt;/strong&gt; My DecisionRequest has a &lt;code&gt;BigDecimal&lt;/code&gt; price field. I annotated it &lt;code&gt;@NotBlank&lt;/code&gt; like the String fields around it, sent a request, and got a 500 with &lt;code&gt;UnexpectedTypeException: No validator could be found for constraint&lt;/code&gt;. Not a validation failure. A crash.&lt;/p&gt;

&lt;p&gt;The reason follows from the whitespace point earlier: &lt;code&gt;@NotBlank&lt;/code&gt; is a text-only concept, so it only works on CharSequence types. There is no such thing as a whitespace BigDecimal. For anything that is not text, the right annotation is &lt;code&gt;@NotNull&lt;/code&gt;, and if the value itself matters there are number-specific checks like &lt;code&gt;@Positive&lt;/code&gt; and &lt;code&gt;@DecimalMin&lt;/code&gt;. My rule of thumb now: &lt;code&gt;@NotBlank&lt;/code&gt; for text, &lt;code&gt;@NotNull&lt;/code&gt; for everything else, &lt;code&gt;@NotEmpty&lt;/code&gt; for collections that need at least one element.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake two: the same rules everywhere.&lt;/strong&gt; My login DTO originally had the full password &lt;code&gt;@Pattern&lt;/code&gt; on it, copied straight from RegisterRequest. Looks consistent. Then I realized what it would do: if I ever tightened the complexity rule, every existing user whose password predates the rule would fail validation at login. They could not even get in to change it.&lt;/p&gt;

&lt;p&gt;Login now only checks that email and password are present. Registration is where strength rules belong, because that is the only moment a password is being chosen. Same field, different context, different rules. Validation belongs to the use case, not to the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest notes
&lt;/h2&gt;

&lt;p&gt;One simplification to own up to: in a secured app the request meets the security filter chain before any of these checkpoints. My register endpoint is open (&lt;code&gt;permitAll&lt;/code&gt;), so the three checkpoints are the whole story for this request, but on a protected endpoint the filters get the first word. Constraints placed directly on &lt;code&gt;@RequestParam&lt;/code&gt; or &lt;code&gt;@PathVariable&lt;/code&gt; arguments fail with yet another exception (&lt;code&gt;HandlerMethodValidationException&lt;/code&gt; in current Spring), so query-parameter validation needs its own handler; I have not needed it yet. Bean Validation also goes deeper than I have gone: nested objects, class-level rules that compare two fields, fully custom validators. I will write about those when my API actually uses them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;A request crosses three checkpoints: deserialization, validation, business rules. Each rejects a different kind of bad, each fails with a different exception, and all three should report through the same handler in the same error shape. Validation itself is one dependency, annotations with human-readable messages on the DTO, and &lt;code&gt;@Valid&lt;/code&gt; on the parameter. And the two rules my mistakes taught me: match the annotation to the field's type, and match the rules to the use case.&lt;/p&gt;

&lt;p&gt;When a request fails three validations at once, my API returns all three messages joined in one string. How do you handle it: first error only, everything in one string, or a structured per-field map? Tell me in the comments. I am genuinely curious what clients prefer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;P.S. If this was useful, subscribe on &lt;a href="https://vaishnaviagrawal1.substack.com" rel="noopener noreferrer"&gt;Substack&lt;/a&gt;. I write one piece like this every week.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Cleaned Up Error Handling in My Spring Boot API</title>
      <dc:creator>Vaishnavi Agrawal</dc:creator>
      <pubDate>Thu, 09 Jul 2026 15:20:38 +0000</pubDate>
      <link>https://dev.to/vaishnavi_agrawal_c7f7d72/how-i-cleaned-up-error-handling-in-my-spring-boot-api-1ene</link>
      <guid>https://dev.to/vaishnavi_agrawal_c7f7d72/how-i-cleaned-up-error-handling-in-my-spring-boot-api-1ene</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4luhr4mkbt1c1owf280v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4luhr4mkbt1c1owf280v.png" alt="Dark navy cover card titled " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Welcome to my newsletter. Every week I take one backend topic, learn it properly, and write down what clicked, with working code from something I actually built. This week: exception handling.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When I built the first version of my Purchase Decision API, my error handling was scattered everywhere. Almost every controller method had its own try-catch block. Looking up a user that did not exist returned one error shape, registering with an existing email returned another, and anything unexpected leaked Spring's default error response to the client.&lt;/p&gt;

&lt;p&gt;None of it was broken, exactly. It was worse: it was inconsistent. Every new endpoint meant copying error-handling code from an older one and hoping I kept the format the same. I usually did not.&lt;/p&gt;

&lt;p&gt;Here is the pattern I settled on instead: custom exceptions plus one global handler with @ControllerAdvice. It is a small change, and it cleaned up my controllers more than anything else I have done to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea in one picture
&lt;/h2&gt;

&lt;p&gt;Think of a store where every employee handles complaints their own way. One offers a refund, one apologizes and does nothing, and one argues. Customers get a different experience depending on who they happen to reach. Now put a single trained person at a complaints desk and give every employee the same instruction: send complaints there. Suddenly, every complaint gets the same treatment.&lt;/p&gt;

&lt;p&gt;@ControllerAdvice is the complaints desk. It is a global exception handler that catches exceptions thrown anywhere in your controllers and handles them in one central place, instead of you repeating try-catch blocks in every controller method. Your controllers stay focused on their actual job, and every endpoint returns errors in the same format.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F72tc6kojussxdsl1die6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F72tc6kojussxdsl1die6.png" alt="Flow diagram titled " width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: name your errors
&lt;/h2&gt;

&lt;p&gt;The first thing I did was turn my failure cases into small, specific exception classes. In my API, the three that come up constantly are a missing user, a duplicate email at registration, and a wrong password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserNotFoundException&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;UserNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailAlreadyExistsException&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;EmailAlreadyExistsException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvalidPasswordException&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;InvalidPasswordException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They extend RuntimeException so I can throw them from my service layer without declaring them everywhere. The service just states what went wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No user with email "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what the service does not do: it does not know or care what HTTP status this becomes. That decision lives somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: decide what an error looks like
&lt;/h2&gt;

&lt;p&gt;Before the handler, I defined one shape that every error response uses. Mine is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A client calling my API now knows that any error, from any endpoint, looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Not Found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No user with email priya@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-08T14:32:10Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That consistency is the whole point. Frontend code can handle errors in one place too, because it can rely on the shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the complaints desk itself
&lt;/h2&gt;

&lt;p&gt;One class, annotated with @ControllerAdvice, catches each exception and maps it to the right status code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Slf4j&lt;/span&gt;
&lt;span class="nd"&gt;@ControllerAdvice&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GlobalExceptionHandler&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserNotFoundException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleUserNotFound&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserNotFoundException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EmailAlreadyExistsException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleEmailExists&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EmailAlreadyExistsException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFLICT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvalidPasswordException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleInvalidPassword&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvalidPasswordException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleUnexpected&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// log the real error for ourselves, return something safe to the client&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unexpected error"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INTERNAL_SERVER_ERROR&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Something went wrong on our side."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ErrorResponse&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getReasonPhrase&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Picking the status codes was its own small lesson. A missing user is 404 Not Found. A duplicate email is 409 Conflict, because the request is fine but it collides with existing state. A wrong password is 401 Unauthorized. Before this refactor I returned 400 for almost everything, which technically worked and helped nobody.&lt;/p&gt;

&lt;p&gt;The last handler matters most. Anything I did not predict gets caught, logged with its stack trace on the server, and turned into a generic 500 for the client. The client never sees internals, and I still get the full details in my logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;My controllers went from this kind of thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;UserDto&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getByEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserNotFoundException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* hand-built error */&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getByEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the global handler, I would repeat the same error-handling code in every controller method and risk formatting errors differently each time. With it, I write each error's handling once, every endpoint returns errors the same way, and my controllers stay focused on their actual logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest notes
&lt;/h2&gt;

&lt;p&gt;Two things I want to be upfront about. First, this is the pattern that works for my project's current size; I am sure there are refinements I have not needed yet. Second, Spring Boot 3 has a built-in ProblemDetail type that follows an RFC standard for error bodies. I have not used it in this project, and trying it is on my list. If you use it and like it, I would genuinely like to hear why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;Scattered try-catch blocks mean duplicated code and inconsistent errors. The fix I settled on: specific exception classes thrown from the service layer, one ErrorResponse shape, and one @ControllerAdvice class that maps each exception to the right status code, with a logging catch-all so nothing leaks. Controllers get shorter, clients get predictable errors, and adding a new error case is one small handler method instead of another copy-pasted try-catch.&lt;/p&gt;

&lt;p&gt;How do you shape your error responses, and did you settle on something different? Tell me in the comments. I compare notes gladly.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;P.S. If this was useful, subscribe. I write one piece like this every week.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
