<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Vaishnavi Agrawal]]></title><description><![CDATA[One practical backend piece a week - Java, Spring, AI tooling, and the concepts that finally clicked. By a backend dev building in public.]]></description><link>https://vaishnaviagrawal1.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!OTEl!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fvaishnaviagrawal1.substack.com%2Fimg%2Fsubstack.png</url><title>Vaishnavi Agrawal</title><link>https://vaishnaviagrawal1.substack.com</link></image><generator>Substack</generator><lastBuildDate>Fri, 24 Jul 2026 08:11:51 GMT</lastBuildDate><atom:link href="https://vaishnaviagrawal1.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Vaishnavi Agrawal]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[vaishnaviagrawal1@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[vaishnaviagrawal1@substack.com]]></itunes:email><itunes:name><![CDATA[Vaishnavi Agrawal]]></itunes:name></itunes:owner><itunes:author><![CDATA[Vaishnavi Agrawal]]></itunes:author><googleplay:owner><![CDATA[vaishnaviagrawal1@substack.com]]></googleplay:owner><googleplay:email><![CDATA[vaishnaviagrawal1@substack.com]]></googleplay:email><googleplay:author><![CDATA[Vaishnavi Agrawal]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Your AI Feature Should Not Break Your Endpoint]]></title><description><![CDATA[The verdict is pure Java. The AI only explains it. How a purchase decision API keeps working when the LLM is down, rate-limited, or wrong.]]></description><link>https://vaishnaviagrawal1.substack.com/p/your-ai-feature-should-not-break</link><guid isPermaLink="false">https://vaishnaviagrawal1.substack.com/p/your-ai-feature-should-not-break</guid><dc:creator><![CDATA[Vaishnavi Agrawal]]></dc:creator><pubDate>Thu, 23 Jul 2026 16:31:31 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/3a80c42e-9f63-46fa-aded-d86288845451_1200x627.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Welcome back. This is the third article about my Purchase Decision API, after <a href="https://vaishnaviagrawal1.substack.com/p/how-i-cleaned-up-error-handling-in">error handling</a> and <a href="https://vaishnaviagrawal1.substack.com/p/stop-bad-requests-at-the-door-with">validation</a>. This week the API got an AI feature, and this piece is about the design decision that mattered most.</em></p><p>Everyone is adding LLMs to their APIs right now. Almost nobody talks about what happens when the LLM is down.</p><p>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.</p><p>The most important design decision I made was this: <strong>the AI cannot touch the verdict.</strong></p><h2>The obvious design, and why I did not use it</h2><p>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.</p><p>I did not do it, for three reasons.</p><p>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.</p><p>Second, it couples my uptime to someone else&#8217;s. If the model provider has an outage, my endpoint has an outage.</p><p>Third, and this one settled it: a hallucinated verdict about someone&#8217;s money is worse than no verdict at all.</p><h2>A floor and a ceiling</h2><p>So I split the response into two layers:</p><pre><code><code>CEILING   LLM explanation          (nice to have, can fail)
FLOOR     score, verdict, plan     (pure Java, never fails)</code></code></pre><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!BAF-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!BAF-!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png 424w, https://substackcdn.com/image/fetch/$s_!BAF-!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png 848w, https://substackcdn.com/image/fetch/$s_!BAF-!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png 1272w, https://substackcdn.com/image/fetch/$s_!BAF-!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!BAF-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png" width="1400" height="780" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:780,&quot;width&quot;:1400,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:80541,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://vaishnaviagrawal1.substack.com/i/208216737?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!BAF-!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png 424w, https://substackcdn.com/image/fetch/$s_!BAF-!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png 848w, https://substackcdn.com/image/fetch/$s_!BAF-!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png 1272w, https://substackcdn.com/image/fetch/$s_!BAF-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb73454ea-eeef-4a26-a95c-d5e062d0b0a2_1400x780.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>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.</p><p>The ceiling is everything that makes the response nicer but is allowed to fail. The AI explanation lives here, and only here.</p><h2>Tell the model the answer</h2><p>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:</p><pre><code><code>String prompt =
    "A user wants to buy '" + itemName + "' for &#8377;" + price + ". " +
    "Their monthly disposable income is &#8377;" + disposableIncome + ". " +
    "The affordability verdict is " + verdict + " with a score of " + score + "/100. " +
    "Explain this verdict in 2-3 friendly, non-judgmental sentences. " +
    "Do not repeat the numbers back mechanically &#8212; give practical, warm advice.";</code></code></pre><p>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.</p><p>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. <strong>The blast radius of a bad AI response is prose, not money.</strong></p><h2>The fallback</h2><p>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:</p><pre><code><code>try {
    return generateExplanation(prompt);
} catch (Exception e) {
    e.printStackTrace();   // more on this line in a second
    return buildFallbackExplanation(itemName, price, verdict, score, disposableIncome);
}</code></code></pre><p>Here are the two outputs side by side, from the same request.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!bmnE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!bmnE!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png 424w, https://substackcdn.com/image/fetch/$s_!bmnE!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png 848w, https://substackcdn.com/image/fetch/$s_!bmnE!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png 1272w, https://substackcdn.com/image/fetch/$s_!bmnE!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!bmnE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png" width="1400" height="620" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:620,&quot;width&quot;:1400,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:122011,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://vaishnaviagrawal1.substack.com/i/208216737?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!bmnE!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png 424w, https://substackcdn.com/image/fetch/$s_!bmnE!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png 848w, https://substackcdn.com/image/fetch/$s_!bmnE!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png 1272w, https://substackcdn.com/image/fetch/$s_!bmnE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2bac2078-e548-498f-bb19-68a23d5897e4_1400x620.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p></p><p>The AI version:</p><blockquote><p>&#8220;It looks like investing in those Sony headphones might not be the best choice right now, considering your monthly income and expenditures...&#8221;</p></blockquote><p>The fallback version:</p><blockquote><p>&#8220;Based on your finances, buying Sony headphones for &#8377;30000 scored 45/100, giving a verdict of WAIT. This is measured against your monthly disposable income of &#8377;27000.&#8221;</p></blockquote><p>Worse prose. Identical information. <strong>The user loses warmth, never the answer.</strong> That is what graceful degradation actually means: the experience gets worse, the contract does not.</p><p>And yes, <code>catch (Exception e)</code> 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.</p><h2>The bug that made me respect logging</h2><p>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.</p><p>The fallback was working too well. It was hiding its own trigger.</p><p>Once I added logging, the cause turned out to be one missing character. I had written <code>"Bearer" + apiKey</code>. No space after Bearer. The auth header was malformed, OpenAI rejected every single call, and the fallback fired every time, exactly as designed.</p><p>That is the uncomfortable property of graceful degradation: <strong>a fallback that logs nothing is undebuggable.</strong> 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.</p><h2>Honest notes</h2><p>A few things this design does not solve.</p><p>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.</p><p>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 <a href="https://vaishnaviagrawal1.substack.com/p/how-i-cleaned-up-error-handling-in">error handling article</a>. Catching everything is a deliberate choice for the optional layer, not a general style.</p><p>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?</p><p>I am still learning this area. This design is simply what survived contact with my own bugs.</p><h2>Recap</h2><p>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.</p><p>Let the AI reduce what your user has to read. Never let it decide what your system guarantees.</p><p>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.</p><p><em>P.S. If this was useful, subscribe. I write one piece like this every week.</em></p>]]></content:encoded></item><item><title><![CDATA[Stop Bad Requests at the Door with Spring Validation]]></title><description><![CDATA[A bad request crosses three checkpoints before your code runs. Where each check belongs, and the two mistakes that taught me.]]></description><link>https://vaishnaviagrawal1.substack.com/p/stop-bad-requests-at-the-door-with</link><guid isPermaLink="false">https://vaishnaviagrawal1.substack.com/p/stop-bad-requests-at-the-door-with</guid><dc:creator><![CDATA[Vaishnavi Agrawal]]></dc:creator><pubDate>Thu, 16 Jul 2026 04:02:05 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!5VGI!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!5VGI!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!5VGI!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png 424w, https://substackcdn.com/image/fetch/$s_!5VGI!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png 848w, https://substackcdn.com/image/fetch/$s_!5VGI!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png 1272w, https://substackcdn.com/image/fetch/$s_!5VGI!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!5VGI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png" width="1456" height="764" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:764,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:783223,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://vaishnaviagrawal1.substack.com/i/207239871?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!5VGI!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png 424w, https://substackcdn.com/image/fetch/$s_!5VGI!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png 848w, https://substackcdn.com/image/fetch/$s_!5VGI!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png 1272w, https://substackcdn.com/image/fetch/$s_!5VGI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F299309af-603e-4ce7-8f36-1a4f10260081_2400x1260.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><em>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.</em></p><p>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.</p><p>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 <a href="https://vaishnaviagrawal1.substack.com/p/how-i-cleaned-up-error-handling-in">last week&#8217;s refactor</a>: the same checks copied into every endpoint, and controllers filling up with code that had nothing to do with their actual job.</p><p>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 &#8220;bad&#8221; belongs. That is what this article is really about.</p><h2>The journey of a bad request</h2><p>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:</p><ol><li><p><strong>Deserialization.</strong> Jackson turns the raw JSON into my <code>RegisterRequest</code> 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.</p></li><li><p><strong>Validation.</strong> The object exists; now @Valid 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.</p></li><li><p><strong>Business rules.</strong> 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 <code>EmailAlreadyExistsException</code> from last week, not a validation error.</p></li></ol><p>The distinction that clicked for me: <strong>validation answers &#8220;is this data well-formed?&#8221;, business rules answer &#8220;is this data acceptable right now?&#8221;</strong>. &#8220;Email must look like an email&#8221; is checkpoint 2. &#8220;Email must not be taken&#8221; 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.</p><p>Each checkpoint also fails with a different exception, which matters later: checkpoint 1 throws <code>HttpMessageNotReadableException</code>, checkpoint 2 throws <code>MethodArgumentNotValidException</code>, checkpoint 3 throws whatever my service decides. Three kinds of bad, three signals.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!gXaI!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!gXaI!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png 424w, https://substackcdn.com/image/fetch/$s_!gXaI!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png 848w, https://substackcdn.com/image/fetch/$s_!gXaI!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png 1272w, https://substackcdn.com/image/fetch/$s_!gXaI!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!gXaI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png" width="1456" height="582" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/bdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:582,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:196258,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://vaishnaviagrawal1.substack.com/i/207239871?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!gXaI!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png 424w, https://substackcdn.com/image/fetch/$s_!gXaI!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png 848w, https://substackcdn.com/image/fetch/$s_!gXaI!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png 1272w, https://substackcdn.com/image/fetch/$s_!gXaI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbdcdf55d-4af2-48aa-90c4-ff1959e4720f_2800x1120.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>The one dependency everyone forgets</h2><p>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, <code>spring-boot-starter-web</code> ships without it, so you can write validation annotations all day and @Valid will silently do nothing.</p><pre><code><code>&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-validation&lt;/artifactId&gt;
&lt;/dependency&gt;</code></code></pre><p>If your validation &#8220;does not work&#8221;, check this first. In Spring Boot 3 the annotations live in <code>jakarta.validation</code>, not the <code>javax.validation</code> you will still see in older tutorials.</p><h2>Checkpoint 2 in practice: rules live on the DTO</h2><p>Bean Validation puts the rules on the data itself. My real <code>RegisterRequest</code>:</p><pre><code><code>@Data
@AllArgsConstructor
@NoArgsConstructor
public class RegisterRequest {

    @NotBlank(message = "Name is required")
    @Size(max = 100, message = "Name cannot exceed 100 characters")
    private String name;

    @NotBlank(message = "Email is required")
    @Email(message = "Email should be valid")
    private String email;

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    @Pattern(
            regexp = "^(?=.*[0-9])(?=.*[A-Z])(?=.*[!@#$%^&amp;*()_+\\-=\\[\\]{};':\"\\\\|,.&lt;&gt;/?]).*$",
            message = "Password must contain at least one number, one uppercase letter, and one symbol"
    )
    private String password;
}</code></code></pre><p>The rules sit next to the fields they protect, the <code>message</code> 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); @Size handles length separately, so each failure produces its own message.</p><p>One subtlety worth knowing: @NotBlank and @NotNull are not interchangeable. A password of three spaces passes @NotNull happily. @NotBlank means &#8220;not null, and not just whitespace&#8221;, which is a text-only concept. That difference caused my favorite bug of this refactor, but that story comes later.</p><p>Turning the checkpoint on is one word in the controller:</p><pre><code><code>@PostMapping("/register")
public ResponseEntity&lt;AuthResponse&gt; register(@Valid @RequestBody RegisterRequest registerRequest) {
    AuthResponse response = authService.register(registerRequest);
    return ResponseEntity.status(201).body(response);
}</code></code></pre><p>@Valid tells Spring to run the DTO&#8217;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.</p><h2>What a failure actually contains</h2><p>When checkpoint 2 fails, Spring does not hand you a string. <code>MethodArgumentNotValidException</code> carries a <code>BindingResult</code>, which holds one <code>FieldError</code> 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 @NotBlank, @Size, and @Pattern all at once, which is three FieldErrors from a single field. Validation reports every broken rule, not just the first one it finds.</p><p>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 <code>ErrorResponse</code> class. So the handler mines the FieldErrors and folds them into that shape:</p><pre><code><code>@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity&lt;ErrorResponse&gt; handleValidation(MethodArgumentNotValidException ex) {
    String message = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -&gt; error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.joining(", "));

    return ResponseEntity.status(400).body(
            new ErrorResponse(400, message, LocalDateTime.now())
    );
}</code></code></pre><p>One new method in the same GlobalExceptionHandler, no new error system. The client gets:</p><pre><code><code>{
  "status": 400,
  "message": "email: Email is required, password: Password must be at least 8 characters",
  "timestamp": "2026-07-14T18:20:41"
}</code></code></pre><p>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.</p><h2>The checkpoint I forgot to handle</h2><p>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 <code>HttpMessageNotReadableException</code>, which means malformed JSON had no guaranteed answer, let alone the right one.</p><p>The right one is a 400. A 500 says &#8220;we broke&#8221;; malformed JSON means &#8220;you sent garbage&#8221;. The fix is the same pattern a third time:</p><pre><code><code>@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity&lt;ErrorResponse&gt; handleUnreadable(HttpMessageNotReadableException ex) {
    return ResponseEntity.status(400).body(
            new ErrorResponse(400, "Request body is missing or malformed", LocalDateTime.now())
    );
}</code></code></pre><p>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.</p><h2>Two mistakes that taught me the rules</h2><p><strong>Mistake one: @NotBlank on a number.</strong> My DecisionRequest has a <code>BigDecimal</code> price field. I annotated it @NotBlank like the String fields around it, sent a request, and got a 500 with <code>UnexpectedTypeException: No validator could be found for constraint</code>. Not a validation failure. A crash.</p><p>The reason follows from the whitespace point earlier: @NotBlank 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 @NotNull, and if the value itself matters there are number-specific checks like @Positive and @DecimalMin. My rule of thumb now: @NotBlank for text, @NotNull for everything else, @NotEmpty for collections that need at least one element.</p><p><strong>Mistake two: the same rules everywhere.</strong> My login DTO originally had the full password @Pattern 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.</p><p>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.</p><h2>Honest notes</h2><p>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 (<code>permitAll</code>), 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 @RequestParam or @PathVariable arguments fail with yet another exception (<code>HandlerMethodValidationException</code> 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.</p><h2>Recap</h2><p>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 @Valid on the parameter. And the two rules my mistakes taught me: match the annotation to the field&#8217;s type, and match the rules to the use case.</p><p>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.</p><p><em>P.S. If this was useful, subscribe. I write one piece like this every week.</em></p>]]></content:encoded></item><item><title><![CDATA[How I Cleaned Up Error Handling in My Spring Boot API]]></title><description><![CDATA[Custom exceptions, one error shape, and one @ControllerAdvice class: the pattern I settled on in my Purchase Decision API, with the code.]]></description><link>https://vaishnaviagrawal1.substack.com/p/how-i-cleaned-up-error-handling-in</link><guid isPermaLink="false">https://vaishnaviagrawal1.substack.com/p/how-i-cleaned-up-error-handling-in</guid><dc:creator><![CDATA[Vaishnavi Agrawal]]></dc:creator><pubDate>Thu, 09 Jul 2026 07:12:48 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/6e554ddc-8b37-4c1c-a482-1c6b0e4e9f65_2400x1260.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!DJJG!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!DJJG!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png 424w, https://substackcdn.com/image/fetch/$s_!DJJG!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png 848w, https://substackcdn.com/image/fetch/$s_!DJJG!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png 1272w, https://substackcdn.com/image/fetch/$s_!DJJG!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!DJJG!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png" width="1456" height="764" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:764,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1514131,&quot;alt&quot;:&quot;Dark navy cover card titled \&quot;One complaints desk for your Spring Boot API\&quot; with the subtitle \&quot;Cleaning up error handling with custom exceptions and @ControllerAdvice\&quot;. Status code chips for 404, 409, 401, and 500 sit above the title, with a golden desk bell in the corner.&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://vaishnaviagrawal1.substack.com/i/206242707?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Dark navy cover card titled &quot;One complaints desk for your Spring Boot API&quot; with the subtitle &quot;Cleaning up error handling with custom exceptions and @ControllerAdvice&quot;. Status code chips for 404, 409, 401, and 500 sit above the title, with a golden desk bell in the corner." title="Dark navy cover card titled &quot;One complaints desk for your Spring Boot API&quot; with the subtitle &quot;Cleaning up error handling with custom exceptions and @ControllerAdvice&quot;. Status code chips for 404, 409, 401, and 500 sit above the title, with a golden desk bell in the corner." srcset="https://substackcdn.com/image/fetch/$s_!DJJG!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png 424w, https://substackcdn.com/image/fetch/$s_!DJJG!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png 848w, https://substackcdn.com/image/fetch/$s_!DJJG!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png 1272w, https://substackcdn.com/image/fetch/$s_!DJJG!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F02955702-50c9-4069-8233-4b5683c7d09a_2400x1260.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><em>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.</em></p><p>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&#8217;s default error response to the client.</p><p>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.</p><p>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.</p><h2>The idea in one picture</h2><p>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.</p><p>@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.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!hsj7!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!hsj7!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png 424w, https://substackcdn.com/image/fetch/$s_!hsj7!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png 848w, https://substackcdn.com/image/fetch/$s_!hsj7!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png 1272w, https://substackcdn.com/image/fetch/$s_!hsj7!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!hsj7!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png" width="1456" height="582" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:582,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:222392,&quot;alt&quot;:&quot;Flow diagram titled \&quot;One complaints desk for the whole API.\&quot; A client request goes to a controller, which throws UserNotFoundException, EmailAlreadyExistsException, or InvalidPasswordException. The exceptions are caught by a single @ControllerAdvice class that maps them to status 404, 409, or 401, with a catch-all that logs and returns 500. The output is one consistent JSON ErrorResponse with status, error, message, and timestamp fields.&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://vaishnaviagrawal1.substack.com/i/206242707?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Flow diagram titled &quot;One complaints desk for the whole API.&quot; A client request goes to a controller, which throws UserNotFoundException, EmailAlreadyExistsException, or InvalidPasswordException. The exceptions are caught by a single @ControllerAdvice class that maps them to status 404, 409, or 401, with a catch-all that logs and returns 500. The output is one consistent JSON ErrorResponse with status, error, message, and timestamp fields." title="Flow diagram titled &quot;One complaints desk for the whole API.&quot; A client request goes to a controller, which throws UserNotFoundException, EmailAlreadyExistsException, or InvalidPasswordException. The exceptions are caught by a single @ControllerAdvice class that maps them to status 404, 409, or 401, with a catch-all that logs and returns 500. The output is one consistent JSON ErrorResponse with status, error, message, and timestamp fields." srcset="https://substackcdn.com/image/fetch/$s_!hsj7!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png 424w, https://substackcdn.com/image/fetch/$s_!hsj7!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png 848w, https://substackcdn.com/image/fetch/$s_!hsj7!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png 1272w, https://substackcdn.com/image/fetch/$s_!hsj7!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F150ec8be-7930-4471-89f2-ca1a11ac99c6_2800x1120.png 1456w" sizes="100vw"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>Step 1: name your errors</h2><p>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:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) {
        super(message);
    }
}

public class EmailAlreadyExistsException extends RuntimeException {
    public EmailAlreadyExistsException(String message) {
        super(message);
    }
}

public class InvalidPasswordException extends RuntimeException {
    public InvalidPasswordException(String message) {
        super(message);
    }
}</code></pre></div><p>They extend RuntimeException so I can throw them from my service layer without declaring them everywhere. The service just states what went wrong:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">User user = userRepository.findByEmail(email)
        .orElseThrow(() -&gt; new UserNotFoundException("No user with email " + email));</code></pre></div><p>Notice what the service does not do: it does not know or care what HTTP status this becomes. That decision lives somewhere else.</p><h2>Step 2: decide what an error looks like</h2><p>Before the handler, I defined one shape that every error response uses. Mine is small:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">public record ErrorResponse(
        int status,
        String error,
        String message,
        Instant timestamp
) {}</code></pre></div><p>A client calling my API now knows that any error, from any endpoint, looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">{
  "status": 404,
  "error": "Not Found",
  "message": "No user with email priya@example.com",
  "timestamp": "2026-07-08T14:32:10Z"
}</code></pre></div><p>That consistency is the whole point. Frontend code can handle errors in one place too, because it can rely on the shape.</p><h2>Step 3: the complaints desk itself</h2><p>One class, annotated with @ControllerAdvice, catches each exception and maps it to the right status code:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity&lt;ErrorResponse&gt; handleUserNotFound(UserNotFoundException ex) {
        return build(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(EmailAlreadyExistsException.class)
    public ResponseEntity&lt;ErrorResponse&gt; handleEmailExists(EmailAlreadyExistsException ex) {
        return build(HttpStatus.CONFLICT, ex.getMessage());
    }

    @ExceptionHandler(InvalidPasswordException.class)
    public ResponseEntity&lt;ErrorResponse&gt; handleInvalidPassword(InvalidPasswordException ex) {
        return build(HttpStatus.UNAUTHORIZED, ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity&lt;ErrorResponse&gt; handleUnexpected(Exception ex) {
        // log the real error for ourselves, return something safe to the client
        log.error("Unexpected error", ex);
        return build(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong on our side.");
    }

    private ResponseEntity&lt;ErrorResponse&gt; build(HttpStatus status, String message) {
        ErrorResponse body = new ErrorResponse(
                status.value(), status.getReasonPhrase(), message, Instant.now());
        return ResponseEntity.status(status).body(body);
    }
}</code></pre></div><p>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.</p><p>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.</p><h2>What actually changed</h2><p>My controllers went from this kind of thing:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">try {
    UserDto user = userService.getByEmail(email);
    return ResponseEntity.ok(user);
} catch (UserNotFoundException ex) {
    return ResponseEntity.status(404).body(/* hand-built error */);
}</code></pre></div><p>to this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">return ResponseEntity.ok(userService.getByEmail(email));</code></pre></div><p>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&#8217;s handling once, every endpoint returns errors the same way, and my controllers stay focused on their actual logic.</p><h2>Honest notes</h2><p>Two things I want to be upfront about. First, this is the pattern that works for my project&#8217;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.</p><h2>Recap</h2><p>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.</p><p>How do you shape your error responses, and did you settle on something different? Tell me in the comments. I compare notes gladly.</p><p><em>P.S. If this was useful, subscribe. I write one piece like this every week.</em></p>]]></content:encoded></item><item><title><![CDATA[How One Property Can Triple Your Spring Boot Throughput]]></title><description><![CDATA[Virtual threads can triple your throughput with one line of config. How they work, what they fix, and where they will not help.]]></description><link>https://vaishnaviagrawal1.substack.com/p/how-one-property-can-triple-your</link><guid isPermaLink="false">https://vaishnaviagrawal1.substack.com/p/how-one-property-can-triple-your</guid><dc:creator><![CDATA[Vaishnavi Agrawal]]></dc:creator><pubDate>Sat, 04 Jul 2026 14:24:53 GMT</pubDate><content:encoded><![CDATA[<p><em>Welcome to my newsletter. Every week I take one backend topic and explain it properly, with working code or a real benchmark to back it up. This week: virtual threads.</em></p><p>Picture a restaurant that assigns one waiter to each table. The waiter takes your order, walks it to the kitchen, and then stands at the pass waiting until the food is ready. He cannot serve anyone else during that wait. If the restaurant employs 200 waiters, it can serve at most 200 tables at once, even though most of those waiters are standing still at any given moment.</p><p>Java web applications have worked exactly this way for decades. The waiter is a thread. The kitchen is your database, or any slow downstream service. Tomcat gives you 200 request threads by default, and when all 200 are waiting on something, request number 201 sits in a queue. Your application is not overloaded with work. It is overloaded with waiting.</p><p>Virtual threads solve this specific problem. In Spring Boot, enabling them takes one line of configuration. This article explains what they actually are, shows the effect with a real benchmark, and covers the cases where they will not help you.</p><h2>Why regular threads run out</h2><p>A traditional Java thread, called a platform thread, is a thin wrapper around an operating system thread. That makes it expensive in two ways. First, the JVM reserves around 1MB of stack memory for each one. Second, the operating system does real work every time it switches between threads. You cannot run hundreds of thousands of platform threads, so frameworks pool a limited number of them and ration access.</p><p>Now consider what a typical backend request does with its thread. It might spend 5 milliseconds doing actual computation and 995 milliseconds waiting for a database query or an API response. The thread does useful work half a percent of the time. The rest of the time it is blocked, and no other request can use it.</p><p>For years, the standard escape from this problem was reactive programming with WebFlux. It works, but it changes how your entire codebase is written, and it makes debugging noticeably harder. Many teams looked at that trade and declined.</p><h2>What a virtual thread actually is</h2><p>A virtual thread is a thread that the JVM manages instead of the operating system. It starts with a stack of a few hundred bytes on the heap, not a 1MB reservation. Creating a million of them is completely fine.</p><p>To your code, a virtual thread looks and behaves like a normal thread. You call blocking methods, you sleep, you write plain sequential logic. The interesting part happens underneath.</p><p>The JVM keeps a small pool of real OS threads called carrier threads, roughly one per CPU core. A virtual thread runs by being mounted on a carrier. When the virtual thread hits a blocking operation, such as a database call, the JVM unmounts it. Its stack is saved to the heap, and the carrier immediately picks up a different virtual thread that has work to do. When the database responds, the JVM mounts the waiting virtual thread on any free carrier, restores its stack, and execution continues as if nothing happened.</p><p>In the restaurant, this is a waiter who hands the order to the kitchen, writes a note about where he left off, and goes to serve another table. When the food is ready, any available waiter can pick up the note and continue from that exact point. The waiting still happens, but it no longer occupies a waiter.</p><p>This is the key insight: virtual threads make blocking cheap. Your code keeps its simple blocking shape, and the JVM turns that blocking into efficient scheduling behind the scenes.</p><h2>Enabling virtual threads in Spring Boot</h2><p>You need Java 21 or newer and Spring Boot 3.2 or newer. Then you add one property:</p><pre><code><code>spring.threads.virtual.enabled=true</code></code></pre><p>With this setting, Tomcat serves each HTTP request on a virtual thread. Spring also moves @Async methods, scheduled tasks, and several other internals onto virtual threads. There is no code change involved.</p><h2>The benchmark</h2><p>To show the effect, here is a minimal Spring Boot 3.5 application with one deliberately slow endpoint. The sleep stands in for a slow downstream call:</p><pre><code><code>@RestController
public class SlowController {

    @GetMapping("/slow")
    public String slow() throws InterruptedException {
        Thread.sleep(1000); // simulates a slow downstream call
        return "served by " + Thread.currentThread();
    }
}</code></code></pre><p>I load tested it with the hey tool, using 600 concurrent clients for 20 seconds on a MacBook with stock settings. First run, default configuration:</p><pre><code><code>Requests/sec:  198.6
Median: 3.02 s    99th percentile: 4.04 s</code></code></pre><p>The throughput number is the 200 thread limit made visible. The app cannot serve more than 200 one-second requests per second, no matter how many clients are asking. The latency tells the same story from the client&#8217;s side. The endpoint takes 1 second, but the median request took 3 seconds. The extra 2 seconds were spent waiting in the queue for a free thread.</p><p>Second run, identical in every way except the one property:</p><pre><code><code>Requests/sec:  593.3
Median: 1.007 s    99th percentile: 1.07 s</code></code></pre><p>Throughput tripled, and it is now limited by the 600 test clients rather than by the application. The median request took 1.007 seconds, which is the sleep plus 7 milliseconds of overhead. The queue is gone.</p><p>The endpoint prints the thread that served each request, and after the change it reads:</p><pre><code><code>served by VirtualThread[#12302,tomcat-handler-12251]/runnable@ForkJoinPool-1-worker-8</code></code></pre><p>That single line shows the whole mechanism: a virtual Tomcat handler thread, running on carrier worker number 8.</p><h2>The synchronized story, and why old advice is outdated</h2><p>If you research virtual threads, you will quickly find warnings about the synchronized keyword. They deserve a short history lesson, because the advice changed recently and much of the internet has not caught up.</p><p>On Java 21 through 23, a virtual thread that blocks inside a synchronized block gets pinned. Pinning means the virtual thread cannot unmount, so it holds its carrier hostage while it waits. With only a handful of carriers, a few pinned threads can stall your whole application. The common recommendation was to replace synchronized with ReentrantLock, and entire migration guides were written around that.</p><p>JDK 24 fixed this with <a href="https://openjdk.org/jeps/491">JEP 491</a>. The JVM now tracks monitor ownership per virtual thread, so synchronized no longer pins. The JEP authors explicitly withdrew the ReentrantLock recommendation. If you are on JDK 24 or newer, you can use whichever construct fits your code.</p><p>Two things still deserve attention. On Java 21 to 23 the old behavior applies, and the risky synchronized blocks usually hide inside dependencies rather than your own code. Apache HTTP Client versions before 5.4 were a known example. And on any Java version, blocking inside native code still pins.</p><p>Rather than guessing, measure. Java Flight Recorder emits an event called jdk.VirtualThreadPinned, enabled by default, whenever a virtual thread stays pinned for more than 20 milliseconds. If that event is quiet, you do not have a pinning problem.</p><h2>What virtual threads do not fix</h2><p>Virtual threads make waiting cheap. They do nothing for actual work.</p><ul><li><p><strong>CPU-bound endpoints.</strong> If your requests spend their time computing rather than waiting, more threads will not make the computation faster. More waiters do not cook the food faster.</p></li><li><p><strong>Streaming and backpressure.</strong> WebFlux still earns its complexity when you need fine control over data flowing through a pipeline of producers and consumers. Virtual threads have no concept of flow control.</p></li><li><p><strong>Downstream bottlenecks.</strong> This one surprises people. Yesterday, at most 200 threads competed for your 10 Hikari database connections. Tomorrow, 10,000 virtual threads can pile onto the same 10 connections. The queue does not disappear. It moves from the web server to the connection pool. Watch your pool metrics after enabling virtual threads, and size the pool deliberately.</p></li></ul><p>A few smaller tips. Do not pool virtual threads, because pooling exists to amortize a cost that virtual threads do not have. Be careful with heavy ThreadLocal caching, since a million threads each holding a cached object adds up. And if you are stuck on Java 21 to 23, audit your older dependencies or let JFR do it for you.</p><h2>Recap</h2><p>Thread-per-request applications hit a wall because blocked threads are wasted capacity, and platform threads are too expensive to overprovision. Virtual threads remove that wall. They are cheap JVM-managed threads that unmount from their carrier whenever they block, so waiting no longer consumes a real thread. On Java 21 and Spring Boot 3.2 or newer, one property enables them, and blocking code scales in a way that used to require a reactive rewrite. The remaining work is making sure the next bottleneck, usually your connection pool, is ready for the traffic that now gets through.</p><p>Are you already running virtual threads in production, or is a Java upgrade standing in the way? I would love to hear where your setup stands, so tell me in the comments.</p><p><em>P.S. If this explanation helped, subscribe. I write one piece like this every week.</em></p>]]></content:encoded></item></channel></rss>