<?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>Derived Queries or Query Annotations in Spring Data JPA</title>
      <dc:creator>Vaishnavi Agrawal</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:57:50 +0000</pubDate>
      <link>https://dev.to/vaishnavi_agrawal_c7f7d72/derived-queries-or-query-annotations-in-spring-data-jpa-4n1e</link>
      <guid>https://dev.to/vaishnavi_agrawal_c7f7d72/derived-queries-or-query-annotations-in-spring-data-jpa-4n1e</guid>
      <description>&lt;p&gt;&lt;em&gt;Welcome back. The last few pieces about my Purchase Decision API were fairly heavy, from &lt;a href="https://vaishnaviagrawal1.substack.com/p/how-i-cleaned-up-error-handling-in" rel="noopener noreferrer"&gt;error handling&lt;/a&gt; to &lt;a href="https://vaishnaviagrawal1.substack.com/p/your-ai-feature-should-not-break" rel="noopener noreferrer"&gt;an AI feature that is allowed to fail&lt;/a&gt; to &lt;a href="https://vaishnaviagrawal1.substack.com/p/why-my-post-request-became-a-get" rel="noopener noreferrer"&gt;a request that arrived as the wrong method&lt;/a&gt;. This one is smaller and more practical: the decision you make every time you add a method to a repository, usually without noticing you made it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;findByEmail&lt;/code&gt; feels like magic. &lt;code&gt;findByUserUserIdAndVerdictAndCreatedAtBetweenOrderByScoreDesc&lt;/code&gt; feels like a warning.&lt;/p&gt;

&lt;p&gt;Both are the same feature. Spring Data JPA reads the name of a method on your repository interface and writes the query for you. You never write the implementation, and for the first few methods it is genuinely delightful.&lt;/p&gt;

&lt;p&gt;Then a requirement arrives that the name can technically express. So you express it. Nothing complains, the tests pass, and you have quietly crossed a line nobody marked.&lt;/p&gt;

&lt;p&gt;This article is about where that line is, and what it costs to cross it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a derived query actually is
&lt;/h2&gt;

&lt;p&gt;It is not magic, and it is not happening at call time. When your application starts, Spring Data parses the method name into a query.&lt;/p&gt;

&lt;p&gt;The vocabulary is a real grammar with a finite set of keywords. Subjects like &lt;code&gt;findBy&lt;/code&gt;, &lt;code&gt;countBy&lt;/code&gt;, &lt;code&gt;existsBy&lt;/code&gt; and &lt;code&gt;deleteBy&lt;/code&gt;. Predicates like &lt;code&gt;And&lt;/code&gt;, &lt;code&gt;Or&lt;/code&gt;, &lt;code&gt;Between&lt;/code&gt;, &lt;code&gt;LessThan&lt;/code&gt;, &lt;code&gt;After&lt;/code&gt;, &lt;code&gt;Containing&lt;/code&gt;, &lt;code&gt;IgnoreCase&lt;/code&gt;. Modifiers like &lt;code&gt;OrderBy&lt;/code&gt;, and &lt;code&gt;First&lt;/code&gt; or &lt;code&gt;Top&lt;/code&gt; for limiting. The full list is in the &lt;a href="https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html" rel="noopener noreferrer"&gt;Spring Data JPA reference&lt;/a&gt;, and it is worth reading once, so you know what is there before reaching for something heavier.&lt;/p&gt;

&lt;p&gt;Most repositories start out looking like 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="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByEmail&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;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;existsByEmail&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;email&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;Two methods, no implementation, and both read like sentences. This is the style at its best.&lt;/p&gt;

&lt;h2&gt;
  
  
  The filename problem
&lt;/h2&gt;

&lt;p&gt;A good filename is &lt;code&gt;invoice.pdf&lt;/code&gt;. A bad one is &lt;code&gt;invoice-march-2026-final-v2-approved-by-finance-REVISED.pdf&lt;/code&gt;. Nobody sat down and decided to write the second one. It grew one qualifier at a time, every single step was reasonable, and the result is a document trying to live inside its own name.&lt;/p&gt;

&lt;p&gt;A derived query method name is a filename. It works while the name is genuinely a name. It stops working when the name becomes the content.&lt;/p&gt;

&lt;p&gt;My own repository has not reached that point. The longest name in it is 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="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByUserUserIdAndVerdict&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That still reads as a sentence. &lt;code&gt;UserUserId&lt;/code&gt; looks odd until you know the rule: Spring walks into the &lt;code&gt;user&lt;/code&gt; relation and reads its &lt;code&gt;userId&lt;/code&gt; field, so nested properties get spelled out by traversal.&lt;/p&gt;

&lt;p&gt;Now imagine two more requirements arrive, a date range and a sort order. The name that expresses them is:&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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByUserUserIdAndVerdictAndCreatedAtBetweenOrderByScoreDesc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Verdict&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;LocalDateTime&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;LocalDateTime&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring is perfectly happy with this. It parses cleanly and returns the right rows. The problem is entirely on the human side: four parameters whose order you work out by re-reading the name, and a name you cannot scan without decoding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The name is checked before your app runs
&lt;/h2&gt;

&lt;p&gt;One real advantage of the derived style is easy to miss.&lt;/p&gt;

&lt;p&gt;If you name a property the entity does not have, by typo or because someone renamed a field, the application fails at startup. You get a &lt;code&gt;PropertyReferenceException&lt;/code&gt;, usually surfacing as "No property found for type". You do not discover it when the endpoint is first called in production. You discover it when the context refuses to start, &lt;a href="https://www.baeldung.com/spring-data-jpa-exception-no-property-found-for-type" rel="noopener noreferrer"&gt;which Baeldung walks through in detail&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That is a genuinely good property, and a reason to keep using derived methods for everything they handle well.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the name stops paying for itself
&lt;/h2&gt;

&lt;p&gt;There is no limit in the framework. Spring will parse a name of any length. So this is judgement rather than a rule, and I would rather say so than invent a threshold and present it as official.&lt;/p&gt;

&lt;p&gt;Three signals that a method name has stopped earning its place:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot read it aloud in one breath.&lt;/strong&gt; The name exists so a reader understands the method without opening anything else. If saying it out loud is work, it has stopped doing its job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You had to count the &lt;code&gt;And&lt;/code&gt;s to get the parameters in the right order.&lt;/strong&gt; Positional parameters governed by a name is a fine arrangement for two of them. At four it is a puzzle, and puzzles get solved wrongly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The name encodes something the reader still has to decode.&lt;/strong&gt; &lt;code&gt;CreatedAtBetween&lt;/code&gt; is decodable. A name carrying three conditions, a range and a sort order is a specification in disguise.&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%2Ftaysmed6t4mx4trxe038.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%2Ftaysmed6t4mx4trxe038.png" alt="Decision diagram titled " width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The same query, written the other way
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@Query&lt;/code&gt; moves the query into an annotation and leaves the method name free to say what the method is for:&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;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
    select d from Decision d
    where d.user.userId = :userId
      and d.verdict = :verdict
      and d.createdAt between :from and :to
    order by d.score desc
    """&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findDecisionsForUserInRange&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"userId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"verdict"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"from"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;LocalDateTime&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"to"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;LocalDateTime&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Longer, and easier to read, which is the trade this whole article is about. The conditions sit on separate lines. The parameters are named rather than positional, so &lt;code&gt;@Param("verdict")&lt;/code&gt; tells you what the second argument is without counting anything. And the method name went back to being a name.&lt;/p&gt;

&lt;p&gt;You also get what a method name cannot express: joins you control, projections into a DTO, aggregate functions, and anything where the query needs to be shaped rather than merely described.&lt;/p&gt;

&lt;p&gt;What you give up is that &lt;code&gt;findByEmail&lt;/code&gt; cannot lie to you about what it does, and &lt;code&gt;findDecisionsForUserInRange&lt;/code&gt; can, because the name and the query are now two things a future change can pull apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safety myth, and the real gap
&lt;/h2&gt;

&lt;p&gt;One claim about this comparison gets repeated often and is worth correcting.&lt;/p&gt;

&lt;p&gt;The claim goes: derived queries are checked at startup so they fail fast, while &lt;code&gt;@Query&lt;/code&gt; is just a string and blows up at runtime. It sounds reasonable. It is not true.&lt;/p&gt;

&lt;p&gt;JPQL inside &lt;code&gt;@Query&lt;/code&gt; is validated at startup as well. During context startup Spring calls &lt;code&gt;EntityManager.createQuery()&lt;/code&gt; for each one, and the JPA specification requires that to throw &lt;code&gt;IllegalArgumentException&lt;/code&gt; for an invalid query string. A typo in your JPQL breaks the boot. It does not wait politely for the first request. A common way to meet this is writing database column names instead of entity field names, which fails immediately, because &lt;a href="https://www.javacodegeeks.com/resolving-the-validation-failed-for-query-for-method-error-in-spring-data-jpa.html" rel="noopener noreferrer"&gt;JPQL is expressed in terms of your entities&lt;/a&gt; rather than your tables.&lt;/p&gt;

&lt;p&gt;So the safety difference is not derived versus &lt;code&gt;@Query&lt;/code&gt;. Both are JPQL underneath, and both get checked.&lt;/p&gt;

&lt;p&gt;The real gap is native queries:&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;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"select * from decisions where user_id = :userId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;nativeQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByUserNative&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"userId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;nativeQuery = true&lt;/code&gt; the string is not parsed as JPQL, so it does not get that startup validation. Your database tells you it is wrong at the moment you run it. That is the actual trade, and it is worth knowing before reaching for native SQL to avoid learning a JPQL construct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I actually am
&lt;/h2&gt;

&lt;p&gt;Honest position: every repository in my project uses derived methods only. I have not written a single &lt;code&gt;@Query&lt;/code&gt; yet, because nothing I have needed has outgrown a method name.&lt;/p&gt;

&lt;p&gt;That is worth saying plainly, because articles comparing two tools usually imply the author switches between them daily. I am writing this from the other side, the side where one tool still covers everything, and I wanted to know where its edge is before I hit it rather than after.&lt;/p&gt;

&lt;p&gt;The rule I am taking from working that out: start with the derived method, and move to &lt;code&gt;@Query&lt;/code&gt; at the point where the name stops being a sentence. It is about readability rather than capability, because capability rarely forces the decision. Derived methods express far more than most people use, so waiting until the name genuinely cannot express the query means writing several names nobody wants to read first.&lt;/p&gt;

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

&lt;p&gt;There is no performance argument in this article, in either direction, and that is a choice rather than an oversight. For equivalent criteria the two produce equivalent queries. Nothing here was measured, so there is no number in this piece, and I would be careful with any article that hands you one without saying how it was produced.&lt;/p&gt;

&lt;p&gt;Native queries buy real power and cost you the startup check and portability across databases. Worth paying sometimes. Not worth paying by accident.&lt;/p&gt;

&lt;p&gt;This is also only about how the query is written, not what it fetches. A tidy derived method on a wide entity can still pull far more than you intended, which is a different problem and a different article.&lt;/p&gt;

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

&lt;p&gt;Spring Data writes your query from your method name, and that name is a filename: excellent while it is a name, a liability once it becomes the content. Derived methods are checked at startup, which is a real benefit, but so is JPQL in &lt;code&gt;@Query&lt;/code&gt;, so failing fast is not what separates them. Only native queries skip the check. Reach for &lt;code&gt;@Query&lt;/code&gt; when the method name stops reading like a sentence, and reach for native SQL deliberately or not at all.&lt;/p&gt;

&lt;p&gt;Where do you draw the line: at three conditions, at the first join, or only when a derived method genuinely cannot express the query? I suspect people are less consistent about this than they think.&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>java</category>
      <category>springboot</category>
      <category>database</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Why My POST Request Became a GET</title>
      <dc:creator>Vaishnavi Agrawal</dc:creator>
      <pubDate>Sat, 08 Aug 2026 08:57:14 +0000</pubDate>
      <link>https://dev.to/vaishnavi_agrawal_c7f7d72/why-my-post-request-became-a-get-1d52</link>
      <guid>https://dev.to/vaishnavi_agrawal_c7f7d72/why-my-post-request-became-a-get-1d52</guid>
      <description>&lt;p&gt;&lt;em&gt;Welcome back. This is the fourth 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;, &lt;a href="https://vaishnaviagrawal1.substack.com/p/stop-bad-requests-at-the-door-with" rel="noopener noreferrer"&gt;validation&lt;/a&gt; and &lt;a href="https://vaishnaviagrawal1.substack.com/p/your-ai-feature-should-not-break" rel="noopener noreferrer"&gt;the AI feature&lt;/a&gt;. Those three were about building it. This one is about the day it broke in a way that made no sense.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Postman said POST. The server said GET. Both were telling the truth.&lt;/p&gt;

&lt;p&gt;The API worked on my machine. I deployed it to Railway, sent the first request, and got a 500. Not a validation error, not an auth error. Just a generic failure on the endpoint that starts the entire flow.&lt;/p&gt;

&lt;p&gt;It took about a day to find, and the fix was three characters long. The interesting part is not the bug. It is that my own error handling had made the bug invisible, and I had written that error handling on purpose, and I had published an article about how nice it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom that made no sense
&lt;/h2&gt;

&lt;p&gt;Every POST and PUT failed on the deployed URL. &lt;code&gt;POST /api/auth/register&lt;/code&gt; is where I fought it, because it is the first call in the flow and nothing else works until it does.&lt;/p&gt;

&lt;p&gt;In Postman, the method dropdown said POST. The body was valid JSON. I screenshotted it more than once, because at some point I stopped trusting my own eyes.&lt;/p&gt;

&lt;p&gt;The platform logs said something else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HttpRequestMethodNotSupportedException: Request method 'GET' is not supported
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over and over. A GET error, on an endpoint I was only ever hitting with POST.&lt;/p&gt;

&lt;p&gt;So I stopped believing the logs. Not consciously, but that is what it amounted to. The working theory was healthcheck pings, or a browser tab somewhere replaying an old URL. Anything except the obvious reading, which was that my request was arriving as a GET, because that was impossible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The logs had the answer the whole time. I filed it as noise.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The false leads
&lt;/h2&gt;

&lt;p&gt;The wrong turns are most of the story, so here they are.&lt;/p&gt;

&lt;p&gt;I chased the JWT secret first. It was still the literal placeholder &lt;code&gt;your-jwt-secret-value&lt;/code&gt;, twenty-one characters, under the minimum length HMAC needs. A genuine bug. I fixed it, redeployed, and the 500 did not move. That is the most expensive kind of red herring: a real problem that is not the problem. Finding it felt like progress and bought me nothing.&lt;/p&gt;

&lt;p&gt;Then I decided the environment variables were not saving, because Railway's UI does not commit an edit until you click the tick and then deploy, which caught me twice. Then I decided I was testing a stale deploy, and for one round that was actually true: the commit that added the logging had not been pushed, so I was reading logs from old code.&lt;/p&gt;

&lt;p&gt;Every theory had the same shape. The cause was out there, in the platform, somewhere I could not inspect. Which conveniently meant I never had to check the one thing I could inspect, which was what my server was actually receiving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing the error to me
&lt;/h2&gt;

&lt;p&gt;The logs were flooded and I was tired of scrolling. So instead of going to the evidence, I brought the evidence to me.&lt;/p&gt;

&lt;p&gt;My global handler was returning a polite, useless message. I changed it temporarily to say what had actually happened:&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;new&lt;/span&gt; &lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"DEBUG: "&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;getClass&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getSimpleName&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not code to leave in. Exception details in a response body are a security problem, and this came straight back out afterwards. But for one deploy, it turns your API client into your log viewer.&lt;/p&gt;

&lt;p&gt;I sent the request again, as a POST, and read the response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DEBUG: HttpRequestMethodNotSupportedException — Request method 'GET' is not supported
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the moment. Not a healthcheck. Not a stray browser tab. &lt;strong&gt;The request I had just sent, arriving as a GET.&lt;/strong&gt;&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%2Fg14pdo3xnxn9rbz54461.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%2Fg14pdo3xnxn9rbz54461.png" alt="Flow diagram titled " width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three characters
&lt;/h2&gt;

&lt;p&gt;I had typed the URL into Postman without a scheme. Postman filled in &lt;code&gt;http://&lt;/code&gt;. The platform edge answered with a redirect to &lt;code&gt;https://&lt;/code&gt;. Postman followed the redirect and re-issued the request as a GET, with no body.&lt;/p&gt;

&lt;p&gt;The fix was typing &lt;code&gt;https://&lt;/code&gt; myself.&lt;/p&gt;

&lt;p&gt;No config change. No nginx, no code. I had spent a day inside my application hunting a bug that happened before my application was ever reached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redirects are allowed to change your method
&lt;/h2&gt;

&lt;p&gt;This is the part I did not know, and it is why the symptom looked impossible.&lt;/p&gt;

&lt;p&gt;Think about posting a filled-in form to an office. The office has moved, so the forwarding service sends your letter on to the new address, except what it delivers is an empty envelope with a note saying this person would like to look at something. The address is honoured. The form you filled in, and the fact that you were submitting rather than browsing, are not.&lt;/p&gt;

&lt;p&gt;That is what a redirect is permitted to do to a POST.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;301&lt;/strong&gt; or a &lt;strong&gt;302&lt;/strong&gt; is permission for a client to rewrite the request method to GET and drop the body. This is not the client misbehaving. &lt;a href="https://www.rfc-editor.org/rfc/rfc7538.html" rel="noopener noreferrer"&gt;RFC 7231 codified it&lt;/a&gt; because it is what browsers had always done, back when redirects mostly pointed at pages to look at rather than forms to submit. The specification is now in line with the behaviour.&lt;/p&gt;

&lt;p&gt;The body disappears for the same reason. A GET is a request to fetch something, so a client that rewrites your POST into a GET has no reason to carry your JSON along. What reaches the server is not a damaged POST. It is a perfectly well formed GET that nobody meant to send.&lt;/p&gt;

&lt;p&gt;Because this caused years of confusion, there are now codes that promise not to do it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;301, 302&lt;/strong&gt;: the client may change your POST to a GET. Most do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;307, 308&lt;/strong&gt;: the method is preserved. These exist specifically to fix the above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;303&lt;/strong&gt;: always converts to GET, deliberately, for the redirect-after-submit pattern.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "redirects break POST requests" is too broad to be true. The accurate version is narrower and more useful: &lt;strong&gt;a 301 or a 302 is allowed to turn your POST into a GET, and your client probably will.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I never captured which code my platform sent, so I will not claim one. The behaviour narrows it down on its own, which is useful to know: if a POST comes out the other side of a redirect as a GET, the response was one of the codes that permit it. A 307 or a 308 could not have produced what I saw.&lt;/p&gt;

&lt;p&gt;Postman is one of those clients that rewrites. Its own documentation has a page titled &lt;a href="https://learning.postman.com/help/resolve-issues/sending-requests/my-request-is-redirected-to-a-get-request" rel="noopener noreferrer"&gt;"My request is redirected to a GET request"&lt;/a&gt;, and its tracker has &lt;a href="https://github.com/postmanlabs/postman-app-support/issues/11410" rel="noopener noreferrer"&gt;an issue describing my exact bug&lt;/a&gt;: http to https, POST arriving as GET. There is a per-request setting called Follow original HTTP method that stops this, and it is off by default.&lt;/p&gt;

&lt;p&gt;Two practical things, then. Type the scheme, and turn that setting on. If you suspect this is happening to you, Postman's console shows the full redirect chain, so you can see the hop and the method change rather than inferring them.&lt;/p&gt;

&lt;h2&gt;
  
  
  My complaints desk shredded the evidence
&lt;/h2&gt;

&lt;p&gt;Now the part that bothers me.&lt;/p&gt;

&lt;p&gt;Spring already had the answer. &lt;code&gt;HttpRequestMethodNotSupportedException&lt;/code&gt; has a natural response, and it is &lt;strong&gt;405 Method Not Allowed&lt;/strong&gt;. A 405 has the rejected method right there in it. A 405 on a request I had just sent as POST would have stopped me in minute one, because the contradiction would have been sitting in the response instead of buried in a log I had decided to distrust.&lt;/p&gt;

&lt;p&gt;I never saw the 405. I had written this, and then written an article praising 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="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;handleAll&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="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;500&lt;/span&gt;&lt;span class="o"&gt;)&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="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Something went wrong"&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;Every exception in the application, including the ones Spring throws with a precise status and a precise message, came out the other side as an anonymous 500. The complaints desk I was so pleased with was taking every complaint, writing "something went wrong" on a fresh form, and shredding the original.&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%2Ffu1jva9n0o7yt0w1s53q.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%2Ffu1jva9n0o7yt0w1s53q.png" alt="Comparison diagram titled " width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The handler did not cause the bug. It replaced a self-diagnosing 405 with an anonymous 500, and destroyed the evidence.&lt;/strong&gt; Then it did something slower and worse: it taught me my own error output was uninformative, which is why I dismissed those logs for a day.&lt;/p&gt;

&lt;p&gt;I wrote nearly this same sentence &lt;a href="https://vaishnaviagrawal1.substack.com/p/your-ai-feature-should-not-break" rel="noopener noreferrer"&gt;three weeks ago&lt;/a&gt;, about an AI fallback that hid its own trigger and cost me days over one missing space. I had not finished learning it. A fallback that swallows silently and a handler that flattens everything are the same mistake at two sizes: &lt;strong&gt;the system keeps working well enough to stop telling you anything.&lt;/strong&gt;&lt;/p&gt;

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

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

&lt;p&gt;The catch-all is not wrong in itself. Something has to be the last line of defence, or an unexpected exception leaks a stack trace to a user. The fix is not deleting it. The fix is logging inside it, and letting framework exceptions that already carry a correct status and message pass through instead of collapsing them into a 500.&lt;/p&gt;

&lt;p&gt;The DEBUG trick is a temporary tool, not a pattern. It puts internal exception detail into a response body. That is fine for one deploy on a project nobody is using yet. It is not fine anywhere real.&lt;/p&gt;

&lt;p&gt;And the hole is still open. My OpenAPI config does not declare a server URL, so Swagger's "Try it out" resolves against whatever scheme the page was loaded with. One line, &lt;code&gt;.addServersItem(new Server().url("https://..."))&lt;/code&gt;, would close it. Writing this article is what made me go and check, and finding it missing is a more honest ending than claiming I had already tidied it up.&lt;/p&gt;

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

&lt;p&gt;My POST request really was arriving as a GET. I had typed a URL without a scheme, the client defaulted to http, the platform redirected to https, and the client did what 301 and 302 allow: re-issued the request as a GET and dropped the body. Use 307 or 308 when the method has to survive, and turn on Follow original HTTP method in Postman.&lt;/p&gt;

&lt;p&gt;Underneath that is the lesson I keep having to relearn. My global exception handler turned a 405 that named the problem into a 500 that named nothing, and a day of my time went into rediscovering what Spring had been ready to tell me immediately.&lt;/p&gt;

&lt;p&gt;Handle your exceptions. Just do not let the handling become the reason you cannot see them.&lt;/p&gt;

&lt;p&gt;When a framework exception already carries a precise status and message, like a 405 or a 415, do you pass it straight through to the client, or normalise everything into one consistent error shape and accept the loss? I have been going back and forth on this and would like to hear which side you land on.&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>java</category>
      <category>springboot</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <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>
