<?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: Avaneesh Yadav</title>
    <description>The latest articles on DEV Community by Avaneesh Yadav (@avaneeshyadav).</description>
    <link>https://dev.to/avaneeshyadav</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%2F4019648%2Ff1e38d7e-19c3-4faa-91f3-0dccd3572a9b.png</url>
      <title>DEV Community: Avaneesh Yadav</title>
      <link>https://dev.to/avaneeshyadav</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/avaneeshyadav"/>
    <language>en</language>
    <item>
      <title>Feature Flags for AI: The Deployment Pattern That Saved Us From 3 Production Disasters</title>
      <dc:creator>Avaneesh Yadav</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:09:46 +0000</pubDate>
      <link>https://dev.to/avaneeshyadav/feature-flags-for-ai-the-deployment-pattern-that-saved-us-from-3-production-disasters-2ehm</link>
      <guid>https://dev.to/avaneeshyadav/feature-flags-for-ai-the-deployment-pattern-that-saved-us-from-3-production-disasters-2ehm</guid>
      <description>&lt;p&gt;Three months after we shipped our first AI feature to production, it broke. Not dramatically — no 500s, no alerts. Just wrong. The model we'd integrated started producing confidently incorrect summaries on a specific document type, and we didn't know until a customer flagged it four days later.&lt;/p&gt;

&lt;p&gt;The fix was straightforward. The &lt;em&gt;deployment&lt;/em&gt; of the fix was not. We had no way to roll it out to 5% of users first. No way to instantly revert to the previous behavior. No kill switch. We pushed the fix to all users at once and watched the dashboards, anxious for the next 48 hours.&lt;/p&gt;

&lt;p&gt;That was the last time we shipped an AI feature without feature flags.&lt;/p&gt;

&lt;p&gt;Here's everything we learned about flagging AI features specifically — the patterns that work, the anti-patterns that burn you, and the Spring Boot implementation that runs our current system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI Features Need Flags More Than Anything Else You've Shipped
&lt;/h2&gt;

&lt;p&gt;Traditional features fail in binary ways: they work or they error. AI features fail in a third way — they work &lt;em&gt;incorrectly&lt;/em&gt;, silently, at a rate that only becomes statistically visible after thousands of requests.&lt;/p&gt;

&lt;p&gt;That changes the deployment calculus entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can't unit test your way to confidence.&lt;/strong&gt; A prompt change that scores 94% on your eval suite can still fail on 2% of production inputs you didn't evaluate. At 100K requests/day, that's 2,000 bad responses a day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model providers change behavior under you.&lt;/strong&gt; Your LLM provider updates the underlying model. Behavior shifts. You didn't deploy anything — but your feature changed. You need a rollback that doesn't involve redeploying your application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt changes are code.&lt;/strong&gt; A new system prompt is not less risky than a new database query. It needs the same gradual rollout, the same observability, the same rollback capability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost spikes hit instantly.&lt;/strong&gt; A new prompt that accidentally uses 3× the tokens will hit your monthly budget within hours of a full rollout. A 1% rollout catches it before it matters.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[AI Feature Change\nmodel / prompt / config] --&amp;gt; B{Feature Flag\nEvaluation}

    B --&amp;gt;|0% rollout| C[Old Behavior\nfor all users]
    B --&amp;gt;|5% rollout| D[New Behavior\nfor 5% of users]
    B --&amp;gt;|100% rollout| E[New Behavior\nfor all users]

    D --&amp;gt;|Metrics look good| F[Increase to 25%]
    D --&amp;gt;|Metrics look bad| G[Instant rollback\nto 0%]
    F --&amp;gt;|Metrics look good| H[Increase to 100%]
    F --&amp;gt;|Metrics look bad| G

    G --&amp;gt;|Takes effect in| I[&amp;lt; 60 seconds\nno redeploy]

    style G fill:#7f1d1d,color:#fca5a5
    style I fill:#065f46,color:#6ee7b7&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  A Taxonomy of AI Feature Flags
&lt;/h2&gt;

&lt;p&gt;Not all AI flags are the same. Mixing them up leads to either over-engineering or missing the ones that matter.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag Type&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;When to use it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Model toggle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which LLM model handles a task&lt;/td&gt;
&lt;td&gt;Model migrations, cost optimizations, provider failover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prompt toggle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which system prompt / prompt template is active&lt;/td&gt;
&lt;td&gt;Any prompt change, A/B testing, prompt experiments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rollout percentage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;What fraction of users see new AI behavior&lt;/td&gt;
&lt;td&gt;Gradual migration, de-risking changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kill switch&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Entire AI feature on/off&lt;/td&gt;
&lt;td&gt;Emergency disable, degraded mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;User segment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;New AI behavior for specific users/tenants&lt;/td&gt;
&lt;td&gt;Beta users, internal testing, enterprise pilot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You need all five. Teams that only implement kill switches miss the gradual rollout pattern that prevents most incidents. Teams that only implement rollout percentages have no emergency lever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Implementation: Spring Boot + Unleash
&lt;/h2&gt;

&lt;p&gt;We use &lt;a href="https://www.getunleash.io" rel="noopener noreferrer"&gt;Unleash&lt;/a&gt; for flag management — open source, self-hostable, strong Java SDK. The patterns below translate directly to LaunchDarkly, Split, Flagsmith, or even a simple database-backed flag store if you're starting out.&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="c"&gt;&amp;lt;!-- pom.xml --&amp;gt;&lt;/span&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;io.getunleash&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;unleash-client-java&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;9.2.0&lt;span class="nt"&gt;&amp;lt;/version&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&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;UnleashConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Unleash&lt;/span&gt; &lt;span class="nf"&gt;unleash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${unleash.url}"&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;url&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${unleash.api-token}"&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;token&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${spring.application.name}"&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;appName&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="nc"&gt;UnleashConfig&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UnleashConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;appName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appName&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instanceId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getenv&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"POD_NAME"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"local"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unleashAPI&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetchTogglesInterval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// poll every 10 seconds&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;synchronousFetchOnInitialisation&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DefaultUnleash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&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;The &lt;code&gt;fetchTogglesInterval(10)&lt;/code&gt; is important: flag changes propagate to all running pods within 10 seconds without a redeploy. That's your 60-second kill switch window.&lt;/p&gt;

&lt;h3&gt;
  
  
  The AI Flag Service
&lt;/h3&gt;

&lt;p&gt;All AI feature flag checks go through a single service — never scattered inline &lt;code&gt;unleash.isEnabled()&lt;/code&gt; calls throughout the codebase.&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;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@Slf4j&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;AIFeatureFlags&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Unleash&lt;/span&gt; &lt;span class="n"&gt;unleash&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// ── Model selection ──────────────────────────────────────────────────────&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;summaryModel&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;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;UnleashContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unleash&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.summary.use-70b-model"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&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="s"&gt;"llama-3.1-70b-versatile"&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="s"&gt;"llama-3.1-8b-instant"&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="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;classifyModel&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;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;UnleashContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unleash&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.classify.use-claude-haiku"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&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="s"&gt;"claude-haiku-4-5-20251001"&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="s"&gt;"llama-3.1-8b-instant"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ── Prompt selection ─────────────────────────────────────────────────────&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;summaryPromptVariant&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;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;UnleashContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// A/B test: "control" vs "variant-a" vs "variant-b"&lt;/span&gt;
        &lt;span class="nc"&gt;Variant&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unleash&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getVariant&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.summary.prompt-variant"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"control"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ── Kill switches ────────────────────────────────────────────────────────&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isSummaryEnabled&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;userId&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="n"&gt;unleash&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.summary.enabled"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&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="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isClassifyEnabled&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;userId&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="n"&gt;unleash&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.classify.enabled"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ── Helpers ──────────────────────────────────────────────────────────────&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;UnleashContext&lt;/span&gt; &lt;span class="nf"&gt;context&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;userId&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;UnleashContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"environment"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getenv&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SPRING_PROFILES_ACTIVE"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"local"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&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 flag check passes a &lt;code&gt;UnleashContext&lt;/code&gt; with the user ID. This is what makes percentage rollouts and user-segment targeting work — Unleash uses the user ID to hash-consistently assign the same user to the same variant across requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: Model Migration Flag
&lt;/h2&gt;

&lt;p&gt;You're running &lt;code&gt;llama-3.1-8b-instant&lt;/code&gt; and want to migrate to &lt;code&gt;claude-haiku-4-5&lt;/code&gt; for better extraction quality. The naive approach: update the constant, deploy, pray.&lt;/p&gt;

&lt;p&gt;The flag approach:&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;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@Slf4j&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;DocumentExtractor&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AIFeatureFlags&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;MeterRegistry&lt;/span&gt; &lt;span class="n"&gt;metrics&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;ExtractionResult&lt;/span&gt; &lt;span class="nf"&gt;extract&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;documentText&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;userId&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;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extractModel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&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;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"extraction model={} userId={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;,&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;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Sample&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;ChatResponse&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;chatClient&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;EXTRACTION_SYSTEM_PROMPT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;documentText&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatOptionsBuilder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;chatResponse&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.extract.latency"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"model"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="c1"&gt;// Model is tagged on every metric — you can compare in Grafana&lt;/span&gt;
        &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.extract.tokens.input"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="s"&gt;"model"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
               &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;increment&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="na"&gt;getMetadata&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getUsage&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getPromptTokens&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.extract.tokens.output"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"model"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
               &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;increment&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="na"&gt;getMetadata&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getUsage&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getGenerationTokens&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;parseResponse&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;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Unleash, configure &lt;code&gt;ai.extract.use-haiku&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start at &lt;strong&gt;0%&lt;/strong&gt; rollout&lt;/li&gt;
&lt;li&gt;Ramp to &lt;strong&gt;5%&lt;/strong&gt; — watch P95 latency and error rate by model in Grafana&lt;/li&gt;
&lt;li&gt;Ramp to &lt;strong&gt;25%&lt;/strong&gt;, &lt;strong&gt;50%&lt;/strong&gt;, &lt;strong&gt;100%&lt;/strong&gt; as metrics look good&lt;/li&gt;
&lt;li&gt;If anything degrades — flip to 0% in the Unleash UI. No redeploy, propagates in 10 seconds
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    subgraph "Week 1"
        A[0% rollout\nBaseline metrics]
    end
    subgraph "Week 2"
        B[5% rollout\nWatch latency + cost]
    end
    subgraph "Week 3"
        C[25% rollout\nWatch quality metrics]
    end
    subgraph "Week 4"
        D[100% rollout\nMigration complete]
    end

    A --&amp;gt;|Metrics OK| B
    B --&amp;gt;|Metrics OK| C
    C --&amp;gt;|Metrics OK| D
    B --&amp;gt;|Issue found| E[Rollback to 0%\nin 10 seconds]
    C --&amp;gt;|Issue found| E

    style E fill:#7f1d1d,color:#fca5a5
    style D fill:#065f46,color:#6ee7b7&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;strong&gt;What to compare:&lt;/strong&gt; latency P95 per model, error rate per model, token cost per model, and — critically — your quality metric (RAGAS score, human thumbs up rate, or whatever you use). The migration is complete when the new model is better or equivalent on all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: Prompt A/B Testing
&lt;/h2&gt;

&lt;p&gt;A new system prompt is not less risky than a code change. It deserves the same scientific treatment.&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;@Component&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;SummaryPromptRegistry&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;PROMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;

        &lt;span class="s"&gt;"control"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"""
            Summarize the following document in under 150 words.
            Focus on the key decisions, numbers, and outcomes.
            """&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="s"&gt;"variant-a"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"""
            You are a senior analyst. Summarize the following document for an executive
            who has 30 seconds to read it. Lead with the most important insight.
            Use bullet points for supporting facts. 150 words maximum.
            """&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="s"&gt;"variant-b"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"""
            Summarize this document. Structure your response as:
            HEADLINE: One sentence capturing the core finding.
            KEY FACTS: 3 bullet points, each under 20 words.
            CONTEXT: One sentence on why this matters.
            """&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;String&lt;/span&gt; &lt;span class="nf"&gt;get&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;variant&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="no"&gt;PROMPTS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PROMPTS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"control"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&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;SummaryService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AIFeatureFlags&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;SummaryPromptRegistry&lt;/span&gt; &lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;MeterRegistry&lt;/span&gt; &lt;span class="n"&gt;metrics&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;SummaryResult&lt;/span&gt; &lt;span class="nf"&gt;summarize&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;content&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;userId&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;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;summaryPromptVariant&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// "control" | "variant-a" | "variant-b"&lt;/span&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="n"&gt;prompts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;ChatResponse&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;chatClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;system&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="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;chatResponse&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Tag every quality signal with the variant — A/B analysis in Grafana&lt;/span&gt;
        &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.summary.requests"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"variant"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;increment&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SummaryResult&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="na"&gt;getResult&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getOutput&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getContent&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;variant&lt;/span&gt;  &lt;span class="c1"&gt;// return variant so callers can record user satisfaction per-variant&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;In Unleash, configure &lt;code&gt;ai.summary.prompt-variant&lt;/code&gt; as a Variants toggle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;control&lt;/code&gt; — 50% of users&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;variant-a&lt;/code&gt; — 25% of users&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;variant-b&lt;/code&gt; — 25% of users&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After 7 days and sufficient sample size, compare thumbs-up rate per variant in your analytics. Promote the winner to 100% by setting all weight to the winning variant, then eventually replace &lt;code&gt;control&lt;/code&gt; with it.&lt;/p&gt;

&lt;p&gt;[!NOTE]&lt;br&gt;
Tag every downstream metric — user satisfaction scores, task completion rates, support tickets — with the prompt variant. The quality metric that matters is business outcome, not LLM eval score.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pattern 3: Graceful Degradation via Kill Switch
&lt;/h2&gt;

&lt;p&gt;When an AI feature breaks, you need to serve &lt;em&gt;something&lt;/em&gt; useful while you fix it. A kill switch paired with a fallback path is the pattern.&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;@Service&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;SmartSearchService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AIFeatureFlags&lt;/span&gt;     &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AISearchService&lt;/span&gt;    &lt;span class="n"&gt;aiSearch&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// semantic vector search&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;KeywordSearchService&lt;/span&gt; &lt;span class="n"&gt;keywordSearch&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// traditional Elasticsearch&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;SearchResults&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchQuery&lt;/span&gt; &lt;span class="n"&gt;query&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;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAiSearchEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// AI search is killed — fall back to keyword search&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;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai-search disabled — falling back to keyword search userId={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;keywordSearch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&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="n"&gt;aiSearch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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="c1"&gt;// Unexpected failure — fall back and alert, but don't surface the error to users&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;"ai-search failed unexpectedly — falling back: {}"&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;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.search.fallback"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"reason"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"exception"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;increment&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;keywordSearch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kill switch gives you three modes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normal&lt;/strong&gt; — AI search for everyone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial kill&lt;/strong&gt; — AI search for 0% of users (flag disabled), keyword search for all. No service degradation, just capability downgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic fallback&lt;/strong&gt; — AI search throws an unexpected error, keyword search runs transparently
&lt;/li&gt;
&lt;/ol&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Search Request] --&amp;gt; B{flags.isAiSearchEnabled?}
    B --&amp;gt;|true| C[AI Semantic Search]
    B --&amp;gt;|false — flag killed| D[Keyword Fallback]
    C --&amp;gt;|Success| E[Return AI Results]
    C --&amp;gt;|Exception| F[Log + Alert]
    F --&amp;gt; D
    D --&amp;gt; G[Return Keyword Results]

    style B fill:#1e3a5f,color:#7dd3fc
    style D fill:#374151,color:#d1d5db
    style F fill:#7f1d1d,color:#fca5a5&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;strong&gt;The key design rule:&lt;/strong&gt; the fallback path must exist &lt;em&gt;before&lt;/em&gt; you ship the AI feature. "We'll add fallback later" means you have no kill switch on day one — exactly when you're most likely to need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4: User-Segment Targeting for Beta Rollouts
&lt;/h2&gt;

&lt;p&gt;Before a public rollout, you want your internal team (and willing beta customers) to dog-food the feature.&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;@Service&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;AIFeatureFlags&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// Internal users get new AI features first&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isNewExtractorEnabled&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;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;userRoles&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;UnleashContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UnleashContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"role"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&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;userRoles&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;unleash&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.extractor.v2"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&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;In Unleash, configure &lt;code&gt;ai.extractor.v2&lt;/code&gt; with activation strategies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;UserWithId&lt;/strong&gt; — list specific internal user IDs: always enabled for the team&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GradualRolloutUserId&lt;/strong&gt; — 5% of everyone else&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means your engineers and internal QA users see the new extractor immediately. External users get it at 5% rollout. You run this for 2 weeks, then ramp external users to 25%, 50%, 100%.&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="c1"&gt;// Usage in a service — no change needed as rollout percentage increases&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ExtractionResult&lt;/span&gt; &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Authentication&lt;/span&gt; &lt;span class="n"&gt;auth&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;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAuthorities&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="nl"&gt;GrantedAuthority:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getAuthority&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;toSet&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNewExtractorEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&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="n"&gt;legacyExtractor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&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="n"&gt;newExtractor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&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;h2&gt;
  
  
  Observability: Linking Flag State to Metrics
&lt;/h2&gt;

&lt;p&gt;Flags are useless without observability. Every request that touches an AI feature should emit metrics tagged with the active flag variant. This is what makes "is variant-A performing better than control?" answerable in Grafana.&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;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Component&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;AIFlagMetricsAspect&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;MeterRegistry&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AIFeatureFlags&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Around&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@annotation(TrackedAIFeature)"&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;Object&lt;/span&gt; &lt;span class="nf"&gt;trackWithFlagContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProceedingJoinPoint&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Extract userId from first String argument (by convention)&lt;/span&gt;
        &lt;span class="nc"&gt;String&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;Arrays&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="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getArgs&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;String&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;a&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;String&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findFirst&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;orElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unknown"&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;model&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;summaryModel&lt;/span&gt;&lt;span class="o"&gt;(&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;String&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;summaryPromptVariant&lt;/span&gt;&lt;span class="o"&gt;(&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;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Sample&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;proceed&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.feature.duration"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"model"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"variant"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&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;Throwable&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.feature.duration"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"model"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"variant"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;t&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Grafana, you now have a single dashboard panel that shows latency by &lt;code&gt;model&lt;/code&gt; and &lt;code&gt;variant&lt;/code&gt; — so model migration and prompt A/B testing are both visible without extra instrumentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The four queries you always want:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Error rate by variant
rate(ai_feature_duration_seconds_count{status="error"}[5m]) 
  / rate(ai_feature_duration_seconds_count[5m])
  by (variant)

# P95 latency by model  
histogram_quantile(0.95, 
  rate(ai_feature_duration_seconds_bucket[5m]))
  by (model, le)

# Cost by model (tokens × price)
rate(ai_tokens_input_total[1h]) by (model)

# Rollout progress (% of requests per variant)
rate(ai_feature_duration_seconds_count[5m]) by (variant)
  / ignoring(variant) group_left
  sum(rate(ai_feature_duration_seconds_count[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Three Disasters We Avoided
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Disaster 1 — Model degradation on a specific doc type.&lt;/strong&gt; Our summarization model started hallucinating numbers in financial documents after a provider-side update. Kill switch disabled AI summarization for affected document categories within 45 seconds of the on-call alert. Users saw the keyword-based fallback. No customer data corrupted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disaster 2 — Prompt regression.&lt;/strong&gt; A new prompt that performed well in offline evals caused a 12% drop in thumbs-up rate in production. We caught it at 5% rollout (affecting ~800 users) instead of rolling it to all 16,000. Rolled back to &lt;code&gt;control&lt;/code&gt; variant in the Unleash UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disaster 3 — Cost spike.&lt;/strong&gt; A batch job that used a new prompt sent to 100% of traffic consumed 4× the expected tokens due to an accidental context window issue. The cost alert fired at $180 (2× rolling average). Kill switch → model flag reverted to the cheaper model → cost normalized in under 2 minutes.&lt;/p&gt;

&lt;p&gt;In all three cases: no redeploy, no hotfix PR, no incident spanning multiple engineers. One person, one flag change, one minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-Patterns
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Checking flags at the wrong layer.&lt;/strong&gt; Putting flag checks inside the LLM integration class couples your infrastructure to your feature logic. Put flags in the service layer, let the LLM client be a dumb executor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One flag for the whole AI system.&lt;/strong&gt; A single &lt;code&gt;ai.enabled&lt;/code&gt; kill switch means you can't surgically kill summarization while keeping classification running. One flag per feature, or per feature + model combination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No fallback for the kill switch.&lt;/strong&gt; A kill switch that returns an error instead of a fallback response is just a way to 500 your users more deterministically. The fallback must exist &lt;em&gt;before&lt;/em&gt; the flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting to tag metrics with flag state.&lt;/strong&gt; If your metrics don't tell you which variant produced which latency, you're flying blind. Tag everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-lived feature branches.&lt;/strong&gt; Don't keep AI behavior in a Git branch while waiting for the flag rollout to complete. Merge to main, deploy behind a 0% flag. The code is in production; the behavior is off. This is trunk-based development for AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting Simple: The Minimum Viable AI Flag
&lt;/h2&gt;

&lt;p&gt;If you're not ready for Unleash or LaunchDarkly, this works:&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="c1"&gt;// Minimal flag store — a database table, or even a config map&lt;/span&gt;
&lt;span class="nd"&gt;@Component&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;SimpleAIFlags&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${ai.summary.enabled:true}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;summaryEnabled&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${ai.summary.model:llama-3.1-8b-instant}"&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;summaryModel&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${ai.summary.rollout-percentage:100}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;rolloutPercentage&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isSummaryEnabled&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;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;summaryEnabled&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// Deterministic hash-based assignment so the same user always gets the same experience&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;abs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hashCode&lt;/span&gt;&lt;span class="o"&gt;()&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;rolloutPercentage&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="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getSummaryModel&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="n"&gt;summaryModel&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;With Spring Boot Actuator, you can update &lt;code&gt;@Value&lt;/code&gt; properties at runtime via &lt;code&gt;/actuator/env&lt;/code&gt; + &lt;code&gt;/actuator/refresh&lt;/code&gt; (with &lt;code&gt;@RefreshScope&lt;/code&gt;). That's your 60-second kill switch without any external system.&lt;/p&gt;

&lt;p&gt;Start here. Add Unleash when you have more than 3 AI features and more than one person managing flag changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI features fail silently.&lt;/strong&gt; Flags let you catch silent failures at 5% before they affect 100% of users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt changes are deployments.&lt;/strong&gt; Treat them with the same rigor: staging, gradual rollout, observability, rollback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the fallback first.&lt;/strong&gt; A kill switch that errors instead of degrading gracefully is worse than no kill switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag every metric with flag state.&lt;/strong&gt; Without this, you can't answer "is the new model better?" from production data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model providers change under you.&lt;/strong&gt; Your flag infrastructure needs to handle changes you didn't trigger, not just changes you did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The kill switch window matters.&lt;/strong&gt; 60 seconds to production effect (flag change → all pods updated) is the target. Anything above 5 minutes is too slow for an active incident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engineering investment is modest — a few days to wire up Unleash and build the flag service. The return is the ability to ship AI features confidently and fix them without waking up your team at 3AM.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The next part of this series covers AI observability specifically: the metrics, traces, and dashboards that make "what exactly did the model return for user X?" answerable in under 30 seconds. If you're already doing feature flags and want to pair them with tighter observability, that's where to look next.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>devops</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Event-Driven AI: How Kafka Streams Changed the Way We Run LLMs in Production</title>
      <dc:creator>Avaneesh Yadav</dc:creator>
      <pubDate>Mon, 31 Aug 2026 15:31:32 +0000</pubDate>
      <link>https://dev.to/avaneeshyadav/event-driven-ai-how-kafka-streams-changed-the-way-we-run-llms-in-production-22mg</link>
      <guid>https://dev.to/avaneeshyadav/event-driven-ai-how-kafka-streams-changed-the-way-we-run-llms-in-production-22mg</guid>
      <description>&lt;p&gt;Last October our AI service handled ~50K LLM requests per day. By February that number hit 800K. By April, 2 million. The synchronous HTTP architecture we had shipped in three weeks didn't survive contact with that growth. We were dropping requests, hitting rate limits that cascaded into total outages, and on-call engineers were getting paged at 3AM for timeouts that shouldn't have been possible to timeout.&lt;/p&gt;

&lt;p&gt;The fix wasn't bigger timeouts or faster hardware. It was rethinking the execution model entirely — from request-response to event-driven. Here's the full architecture, the Spring AI + Kafka code, the failure modes we hit, and the production numbers that came out the other side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Synchronous LLM Calls Break at Scale
&lt;/h2&gt;

&lt;p&gt;The typical AI feature looks like this: a user does something, your API calls an LLM, the LLM responds, you return the result. Simple, works fine in demos, blows up in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLMs are slow and variable.&lt;/strong&gt; A fast response is 300ms. A slow one — complex reasoning, long context, throttled provider — is 15–30 seconds. Most HTTP infrastructure timeouts sit at 30 seconds, which means you're living on the edge constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits cascade.&lt;/strong&gt; Your provider gives you X requests per minute. Under load, one failed batch causes retries that burn more quota, which causes more failures. Exponential backoff helps but doesn't fix the underlying problem: too many concurrent callers sharing a quota.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No buffering.&lt;/strong&gt; If the downstream LLM is temporarily rate-limited or degraded, you have no place to hold work. You either drop the request or return an error to the user.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Sync Architecture&lt;/th&gt;
&lt;th&gt;Event-Driven&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Timeout handling&lt;/td&gt;
&lt;td&gt;30s hard limit → user sees error&lt;/td&gt;
&lt;td&gt;Message retained → retried after backoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate limit response&lt;/td&gt;
&lt;td&gt;HTTP 429 → cascading upstream 500s&lt;/td&gt;
&lt;td&gt;Consumer pauses → catches up when quota resets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traffic spike&lt;/td&gt;
&lt;td&gt;Thread pool exhausts → 503s&lt;/td&gt;
&lt;td&gt;Kafka absorbs burst → smooth processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider degradation&lt;/td&gt;
&lt;td&gt;All in-flight requests fail simultaneously&lt;/td&gt;
&lt;td&gt;New messages queue; consumer retries at its own pace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost visibility&lt;/td&gt;
&lt;td&gt;Invisible per-request cost&lt;/td&gt;
&lt;td&gt;Per-consumer-group token metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Event-Driven AI Model
&lt;/h2&gt;

&lt;p&gt;The core idea: instead of making LLM calls synchronously in the API handler, publish work to a Kafka topic, consume it in a dedicated AI worker, and emit results to a results topic. Callers subscribe to results.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[API Gateway] --&amp;gt;|POST /enrich| B[Request Handler]
    B --&amp;gt;|Publish AITask event| C[(ai.tasks.summarize)]
    B --&amp;gt;|202 Accepted + taskId| D[Client]

    C --&amp;gt;|Consumer Group — 8 threads| E[AI Worker Pool]
    E --&amp;gt;|Rate-limited call| F[LLM Provider]
    F --&amp;gt;|Response| E
    E --&amp;gt;|Publish AIResult| G[(ai.results.completed)]

    G --&amp;gt;|Subscribe| H[Result Handler]
    H --&amp;gt;|Persist| I[(PostgreSQL)]
    H --&amp;gt;|WebSocket push| D

    style C fill:#1e3a5f,color:#7dd3fc
    style G fill:#1e3a5f,color:#7dd3fc
    style F fill:#374151,color:#d1d5db&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The client immediately gets a &lt;code&gt;202 Accepted&lt;/code&gt; with a task ID. The result arrives when the LLM call completes — via WebSocket, SSE, or polling. This separation is what unlocks scale: the HTTP layer and the LLM processing layer evolve independently and fail independently.&lt;/p&gt;

&lt;p&gt;[!NOTE]&lt;br&gt;
This is the same pattern as async email sending, async PDF generation, or async image processing. It's been standard for I/O-heavy work for a decade. LLMs are just I/O with a 10-second P99.&lt;/p&gt;
&lt;h2&gt;
  
  
  Topic Design: What Goes Where
&lt;/h2&gt;

&lt;p&gt;Getting the Kafka topic structure right matters more than any individual line of code. Topics are your API contract — they're hard to change once services depend on them.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    subgraph Input ["Task Topics (by type)"]
        A[ai.tasks.summarize]
        B[ai.tasks.classify]
        C[ai.tasks.extract]
    end

    subgraph Priority ["Priority Routing"]
        E[High-Priority Consumer Group\n8 threads]
        F[Batch Consumer Group\n2 threads]
    end

    subgraph Output ["Result Topics"]
        G[ai.results.completed]
        H[ai.results.failed]
        I[ai.tasks.classify.dlq]
    end

    A --&amp;gt; E
    B --&amp;gt; E
    C --&amp;gt; F

    E --&amp;gt; G
    E --&amp;gt; H
    F --&amp;gt; G
    F --&amp;gt; H
    H --&amp;gt;|After 3 retries| I

    style E fill:#065f46,color:#6ee7b7
    style F fill:#1e3a5f,color:#7dd3fc
    style I fill:#7f1d1d,color:#fca5a5&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;strong&gt;Design principles we landed on:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One topic per task type&lt;/strong&gt;, not one monolithic &lt;code&gt;ai-tasks&lt;/code&gt; topic. Lets you scale consumers independently, set different retention policies (real-time classification results don't need 7-day retention; completed summarizations do), and gives you clear consumer lag metrics per workload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Priority tiers as separate consumer groups&lt;/strong&gt;, not topics. Eight consumer threads watch the high-priority topics; two watch the batch topics. Simple to operate — no complex priority queue logic or message reordering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate DLQ per task type&lt;/strong&gt; (&lt;code&gt;ai.tasks.classify.dlq&lt;/code&gt;, not a shared DLQ). When debugging production failures you already know the task type — a shared DLQ forces you to filter through noise from every other failing type simultaneously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results topic as a public contract.&lt;/strong&gt; Any service can subscribe to &lt;code&gt;ai.results.completed&lt;/code&gt; and react to completed AI tasks. Loose coupling at its best — the AI worker doesn't need to know who cares about the results.&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;@Configuration&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;AITopicConfig&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;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;TASKS_SUMMARIZE&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai.tasks.summarize"&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;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;TASKS_CLASSIFY&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai.tasks.classify"&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;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;RESULTS_COMPLETED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai.results.completed"&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;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;RESULTS_FAILED&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai.results.failed"&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;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;DLQ_CLASSIFY&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai.tasks.classify.dlq"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;NewTopic&lt;/span&gt; &lt;span class="nf"&gt;summarizeTopic&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;TopicBuilder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TASKS_SUMMARIZE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;partitions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// max 12 concurrent consumers in this group&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TopicConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RETENTION_MS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"604800000"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 7 days&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;NewTopic&lt;/span&gt; &lt;span class="nf"&gt;classifyTopic&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;TopicBuilder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TASKS_CLASSIFY&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;partitions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// higher throughput — more partitions&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TopicConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RETENTION_MS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"86400000"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// 1 day&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;NewTopic&lt;/span&gt; &lt;span class="nf"&gt;resultsTopic&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;TopicBuilder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;RESULTS_COMPLETED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;partitions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TopicConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RETENTION_MS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2592000000"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 30 days&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&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;Partition count is a ceiling on parallelism. Kafka consumer group parallelism is bounded by partition count — 12 partitions means you can run at most 12 consumer threads per group before threads sit idle. Size for peak throughput, not current throughput. You can't easily add partitions to a live topic without a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI Task Schema
&lt;/h2&gt;

&lt;p&gt;Every message uses the same envelope. Consistency is what lets you build generic retry, routing, and observability tooling without per-task-type special cases.&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;AITask&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;taskId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// UUID — idempotency key&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// "summarize" | "classify" | "extract"&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// service or user that submitted&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// "high" | "normal" | "batch"&lt;/span&gt;
    &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;submittedAt&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;attemptNumber&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// 1-based; consumers use this for backoff strategy&lt;/span&gt;
    &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// task-specific input data&lt;/span&gt;
    &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;   &lt;span class="c1"&gt;// tracing headers, correlation IDs&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="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;AIResult&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;taskId&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;taskType&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;success&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;completedAt&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt;    &lt;span class="n"&gt;processingMs&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;model&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;inputTokens&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;outputTokens&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt;  &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// task-specific result — Map, String, etc.&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt;  &lt;span class="n"&gt;errorMessage&lt;/span&gt;     &lt;span class="c1"&gt;// null on success&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;Three design decisions worth calling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;taskId&lt;/code&gt; is the idempotency key.&lt;/strong&gt; Consumers check whether a task has already been processed before calling the LLM. Kafka at-least-once delivery means you &lt;em&gt;will&lt;/em&gt; receive duplicates — consumer rebalancing, network glitches, pod restarts. Plan for it from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;attemptNumber&lt;/code&gt; travels with the message&lt;/strong&gt;, not just as a consumer variable. This means retry strategy is encoded in the event itself: first attempt = immediate retry, second = 30s backoff, third = DLQ. Works correctly across pod restarts and consumer rebalancing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token counts in every result.&lt;/strong&gt; Essential for cost tracking. Sum &lt;code&gt;inputTokens + outputTokens&lt;/code&gt; per consumer group, per model, per hour. That's your cost dashboard with no extra instrumentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring AI + Kafka: The Consumer
&lt;/h2&gt;

&lt;p&gt;This is the heart of the system — the consumer that actually processes AI tasks.&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;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@Slf4j&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;AITaskConsumer&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ChatClient&lt;/span&gt;         &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;KafkaTemplate&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AIResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;resultTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AITaskRepository&lt;/span&gt;   &lt;span class="n"&gt;taskRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// idempotency store&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;MeterRegistry&lt;/span&gt;      &lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AdaptiveRateLimiter&lt;/span&gt; &lt;span class="n"&gt;rateLimiter&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;topics&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;AITopicConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TASKS_SUMMARIZE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AITopicConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TASKS_CLASSIFY&lt;/span&gt; &lt;span class="o"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;groupId&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai-worker-high-priority"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;concurrency&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"8"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// 8 consumer threads per pod&lt;/span&gt;
        &lt;span class="n"&gt;containerFactory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"aiListenerContainerFactory"&lt;/span&gt;
    &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;consume&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nd"&gt;@Payload&lt;/span&gt; &lt;span class="nc"&gt;AITask&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nd"&gt;@Header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KafkaHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RECEIVED_TOPIC&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;topic&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;Acknowledgment&lt;/span&gt; &lt;span class="n"&gt;ack&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&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;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"task={} type={} attempt={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attemptNumber&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// Check idempotency — previous consumer may have already handled this&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isCompleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&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;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"task={} already completed — skipping (idempotent ack)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;ack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acknowledge&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nc"&gt;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Sample&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;rateLimiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acquire&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// blocks until a permit is available&lt;/span&gt;

            &lt;span class="nc"&gt;AIResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"summarize"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleSummarize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"classify"&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleClassify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unknown type: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="o"&gt;};&lt;/span&gt;

            &lt;span class="n"&gt;taskRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markComplete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// write idempotency record first&lt;/span&gt;
            &lt;span class="n"&gt;resultTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;RESULTS_COMPLETED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;ack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acknowledge&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// only after result is durably published&lt;/span&gt;

            &lt;span class="n"&gt;rateLimiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onSuccess&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.task.processing"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"success"&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;RateLimitException&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="c1"&gt;// Provider returned 429 — don't ack, let Kafka redeliver after consumer pause&lt;/span&gt;
            &lt;span class="n"&gt;rateLimiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onRateLimit&lt;/span&gt;&lt;span class="o"&gt;();&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;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"task={} rate-limited (attempt={})"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attemptNumber&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="c1"&gt;// Intentionally no ack.acknowledge() — message redelivers&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;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;"task={} failed attempt={}: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attemptNumber&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;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attemptNumber&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sendToDLQ&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&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;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
                &lt;span class="n"&gt;ack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acknowledge&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// remove from main topic — it's in DLQ now&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="c1"&gt;// Otherwise no ack — Kafka redelivers with same attempt number&lt;/span&gt;
            &lt;span class="c1"&gt;// (producer should increment attemptNumber on retry, or use a retry topic)&lt;/span&gt;
            &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.task.processing"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&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;private&lt;/span&gt; &lt;span class="nc"&gt;AIResult&lt;/span&gt; &lt;span class="nf"&gt;handleSummarize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AITask&lt;/span&gt; &lt;span class="n"&gt;task&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;content&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="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"content"&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;maxWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"maxWords"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&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;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""
            Summarize the following in under %d words.
            Be specific — preserve key facts, numbers, and named entities.

            Content:
            %s
            """&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;formatted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxWords&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&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;start&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="nc"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;user&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="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;chatResponse&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;Usage&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMetadata&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getUsage&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AIResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"summarize"&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;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="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;between&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&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="na"&gt;toMillis&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="s"&gt;"llama-3.1-8b-instant"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPromptTokens&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getGenerationTokens&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"summary"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getResult&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getOutput&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getContent&lt;/span&gt;&lt;span class="o"&gt;()),&lt;/span&gt;
            &lt;span class="kc"&gt;null&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;&lt;strong&gt;The &lt;code&gt;ack.acknowledge()&lt;/code&gt; placement is load-bearing.&lt;/strong&gt; We only call it after the result is successfully published to the results topic. If the result publish fails — network blip, Kafka broker restart — Kafka will redeliver the task to another consumer thread. Combined with the idempotency check at the top, the task will be processed exactly once even across failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting happens inside the consumer, not at the API gateway.&lt;/strong&gt; Your LLM provider doesn't know about your queue depth — it just sees requests arriving. Rate limiting at the consumer level means you never exceed quota regardless of how deep the queue gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Backpressure: Adaptive Rate Limiting
&lt;/h2&gt;

&lt;p&gt;The most common failure mode isn't Kafka — it's the LLM provider. When you hit rate limits, the naive response (throw exception, retry immediately) makes things significantly worse. Here's what we do instead:&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;@Component&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;AdaptiveRateLimiter&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// Groq's limit at our tier: 30 req/min = 0.5 req/sec&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;            &lt;span class="n"&gt;maxRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 10% headroom below actual limit&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt;            &lt;span class="n"&gt;minRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// hard floor when throttled&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AtomicDouble&lt;/span&gt;  &lt;span class="n"&gt;currentRate&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;AtomicDouble&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;volatile&lt;/span&gt; &lt;span class="nc"&gt;RateLimiter&lt;/span&gt;     &lt;span class="n"&gt;limiter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;limiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acquire&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onSuccess&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentRate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.05&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxRate&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// +5% on success&lt;/span&gt;
        &lt;span class="n"&gt;updateLimiter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onRateLimit&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentRate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minRate&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// halve on 429&lt;/span&gt;
        &lt;span class="n"&gt;updateLimiter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;);&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;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Rate-limited — throttling to {:.2f} req/sec"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;updateLimiter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;newRate&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;abs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newRate&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;currentRate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;currentRate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newRate&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;limiter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newRate&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is AIMD — Additive Increase, Multiplicative Decrease — the same algorithm TCP congestion control uses. On success, increase by 5%. On rate limit, halve. Over time it converges on the sustainable throughput for your provider tier without manual tuning.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Message dequeued] --&amp;gt; B{Rate limiter\nacquire}
    B --&amp;gt;|Permit granted| C[Call LLM]
    C --&amp;gt;|200 OK| D[Publish result to Kafka]
    D --&amp;gt; E[ack.acknowledge]
    E --&amp;gt;|onSuccess: rate × 1.05| B

    C --&amp;gt;|429 Rate Limited| F[onRateLimit: rate ÷ 2]
    F --&amp;gt; G[No ack — Kafka redelivers]
    G --&amp;gt; A

    C --&amp;gt;|500 or timeout| H{attemptNumber}
    H --&amp;gt;|&amp;lt; 3| G
    H --&amp;gt;|≥ 3| I[sendToDLQ]
    I --&amp;gt; J[ack.acknowledge\nfrom main topic]

    style E fill:#065f46,color:#6ee7b7
    style I fill:#7f1d1d,color:#fca5a5
    style G fill:#1e3a5f,color:#7dd3fc&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Dead Letter Queues: Don't Silently Drop AI Work
&lt;/h2&gt;

&lt;p&gt;A DLQ is your safety net for permanent failures. Every task that fails three times ends up there — unprocessed, with the error reason attached as metadata.&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;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendToDLQ&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AITask&lt;/span&gt; &lt;span class="n"&gt;task&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;errorMessage&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dlqMeta&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;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;dlqMeta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dlq.reason"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;       &lt;span class="n"&gt;errorMessage&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;dlqMeta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dlq.sentAt"&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="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;dlqMeta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dlq.finalAttempt"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valueOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attemptNumber&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

    &lt;span class="nc"&gt;AITask&lt;/span&gt; &lt;span class="n"&gt;dlqTask&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;AITask&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;submittedAt&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attemptNumber&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;dlqMeta&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;dlqTopic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai.tasks."&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;".dlq"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;dlqTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dlqTopic&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;dlqTask&lt;/span&gt;&lt;span class="o"&gt;);&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;"task={} → DLQ after {} attempts: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attemptNumber&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;errorMessage&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.task.dlq"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;increment&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;DLQ messages are retained for 30 days. Twice a week we run a DLQ reprocessing job: identify the root cause, fix the consumer, then replay DLQ messages back into the main topic. Nothing is permanently lost.&lt;/p&gt;

&lt;p&gt;[!WARNING]&lt;br&gt;
Do not set DLQ retention too short. A production incident that takes 3 days to diagnose needs the evidence to still be in the DLQ when you're ready to replay. 30 days is a safe default for AI task DLQs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Exactly-Once Semantics: When You Actually Need Them
&lt;/h2&gt;

&lt;p&gt;Kafka supports exactly-once semantics (EOS) via transactions. For most AI tasks, you don't need them — idempotent consumers plus at-least-once delivery is simpler and sufficient. But for billing events, audit logs, and compliance-critical document processing, true exactly-once matters.&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;@Bean&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;KafkaTransactionManager&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AIResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;kafkaTxManager&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;ProducerFactory&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AIResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pf&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;KafkaTransactionManager&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;pf&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Consumer using Kafka transactions&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kafkaTxManager"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai.tasks.billing-critical"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai-billing"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;consumeExactlyOnce&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Payload&lt;/span&gt; &lt;span class="nc"&gt;AITask&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Acknowledgment&lt;/span&gt; &lt;span class="n"&gt;ack&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Entire method runs in a Kafka transaction.&lt;/span&gt;
    &lt;span class="c1"&gt;// Either ALL of the following happen atomically, or NONE do:&lt;/span&gt;
    &lt;span class="c1"&gt;//   1. Process the AI task&lt;/span&gt;
    &lt;span class="c1"&gt;//   2. Publish the result event&lt;/span&gt;
    &lt;span class="c1"&gt;//   3. Commit the consumer offset&lt;/span&gt;
    &lt;span class="nc"&gt;AIResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;processTask&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;resultTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;RESULTS_COMPLETED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// offset commits atomically with the produce — true exactly-once&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tradeoff: EOS adds roughly 15–20% latency overhead due to transaction coordination. For billing tasks, worth it. For bulk document summarization, not worth it — idempotent at-least-once is the right choice there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability: Tracing AI Events Across Services
&lt;/h2&gt;

&lt;p&gt;An AI task might be submitted by Service A, consumed by AI Worker B, and the result consumed by Service C. When something goes wrong, you need to trace the full journey without manual correlation.&lt;/p&gt;

&lt;p&gt;We propagate OpenTelemetry trace context through Kafka headers:&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="c1"&gt;// Producer — inject trace context into message headers&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;submitTask&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AITask&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProducerRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AITask&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;record&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;ProducerRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;
            &lt;span class="no"&gt;TASKS_SUMMARIZE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;GlobalOpenTelemetry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPropagators&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTextMapPropagator&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;current&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                    &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UTF_8&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;

    &lt;span class="n"&gt;kafkaTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Consumer — extract trace context and create a child span&lt;/span&gt;
&lt;span class="nd"&gt;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(...)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;consume&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Payload&lt;/span&gt; &lt;span class="nc"&gt;AITask&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;@Headers&lt;/span&gt; &lt;span class="nc"&gt;MessageHeaders&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Context&lt;/span&gt; &lt;span class="n"&gt;parentCtx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GlobalOpenTelemetry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPropagators&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTextMapPropagator&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;current&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;headers&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;MessageHeadersGetter&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Span&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;spanBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.task.process"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setParent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parentCtx&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"task.id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"task.type"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskType&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startSpan&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Scope&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;makeCurrent&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AIResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;processTask&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tokens.input"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inputTokens&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tokens.output"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;outputTokens&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"latency.ms"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;processingMs&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;end&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;In Grafana Tempo, the complete trace now shows: &lt;code&gt;HTTP POST /enrich&lt;/code&gt; → &lt;code&gt;Kafka publish&lt;/code&gt; → &lt;code&gt;ai.task.process&lt;/code&gt; (the LLM call spans inside) → &lt;code&gt;Kafka produce result&lt;/code&gt; → &lt;code&gt;result consumer&lt;/code&gt; → &lt;code&gt;database write&lt;/code&gt;. The entire 8-second LLM call is one contiguous trace chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key metrics per consumer group:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;What it reveals&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ai.task.processing&lt;/code&gt; timer&lt;/td&gt;
&lt;td&gt;P50/P95/P99 latency per task type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kafka.consumer.lag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Queue depth — how far behind consumers are&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ai.tokens.input&lt;/code&gt; / &lt;code&gt;ai.tokens.output&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Cost per model per hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ai.task.dlq&lt;/code&gt; counter&lt;/td&gt;
&lt;td&gt;Permanent failure rate — should be near zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai.rate_limiter.throttle_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How often you're hitting provider limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai.task.idempotent.skip_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Duplicates correctly filtered (sanity check)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Configuration That Actually Matters
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kafka&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap-servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KAFKA_BROKERS}&lt;/span&gt;
    &lt;span class="na"&gt;consumer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;group-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-worker-high-priority&lt;/span&gt;
      &lt;span class="na"&gt;auto-offset-reset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;earliest&lt;/span&gt;
      &lt;span class="na"&gt;enable-auto-commit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;          &lt;span class="c1"&gt;# CRITICAL — never use auto-commit for AI tasks&lt;/span&gt;
      &lt;span class="na"&gt;max-poll-records&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;                &lt;span class="c1"&gt;# Don't overwhelm a slow consumer&lt;/span&gt;
      &lt;span class="na"&gt;max-poll-interval-ms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120000&lt;/span&gt;       &lt;span class="c1"&gt;# 2 min — LLM calls can be slow&lt;/span&gt;
      &lt;span class="na"&gt;fetch-min-bytes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;producer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;acks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;                          &lt;span class="c1"&gt;# Wait for all in-sync replicas&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;enable-idempotence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;listener&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ack-mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MANUAL_IMMEDIATE&lt;/span&gt;
      &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;

&lt;span class="c1"&gt;# Tune these per your LLM provider tier&lt;/span&gt;
&lt;span class="na"&gt;ai&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rate-limiter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;max-rate-per-second&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.45&lt;/span&gt;           &lt;span class="c1"&gt;# 27 req/min — 10% below Groq's 30 req/min limit&lt;/span&gt;
    &lt;span class="na"&gt;min-rate-per-second&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.05&lt;/span&gt;
  &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;max-attempts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="na"&gt;dlq-retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;max-poll-interval-ms&lt;/code&gt; is the one that bites everyone. If a consumer takes longer than this value between &lt;code&gt;poll()&lt;/code&gt; calls, Kafka considers it dead and triggers a rebalance — even if the consumer is actively processing a long LLM call. We set it to 120 seconds (double our P99 LLM latency) to avoid unnecessary rebalancing during legitimate slow calls.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max-poll-records: 5&lt;/code&gt; limits how many messages a single consumer thread fetches in one batch. With 8 threads each holding 5 messages, you have 40 LLM calls potentially in-flight per pod. Size this to your rate limit headroom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost Breakdown
&lt;/h2&gt;

&lt;p&gt;After 90 days running this architecture against ~2M requests/day:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Task Type&lt;/th&gt;
&lt;th&gt;Avg Input Tokens&lt;/th&gt;
&lt;th&gt;Avg Output Tokens&lt;/th&gt;
&lt;th&gt;Input $/1M&lt;/th&gt;
&lt;th&gt;Output $/1M&lt;/th&gt;
&lt;th&gt;Daily Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;llama-3.1-8b-instant&lt;/td&gt;
&lt;td&gt;Classification&lt;/td&gt;
&lt;td&gt;450&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;$0.05&lt;/td&gt;
&lt;td&gt;$0.08&lt;/td&gt;
&lt;td&gt;$47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama-3.1-70b&lt;/td&gt;
&lt;td&gt;Summarization&lt;/td&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;$0.59&lt;/td&gt;
&lt;td&gt;$0.79&lt;/td&gt;
&lt;td&gt;$380&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-haiku-4-5&lt;/td&gt;
&lt;td&gt;Extraction&lt;/td&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;$0.80&lt;/td&gt;
&lt;td&gt;$4.00&lt;/td&gt;
&lt;td&gt;$260&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$687/day&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The event-driven architecture contributed two cost wins that weren't possible with synchronous calls:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intelligent model routing.&lt;/strong&gt; The consumer reads &lt;code&gt;task.payload().get("complexity")&lt;/code&gt; — a simple score computed at task submission — and routes simple classifications to &lt;code&gt;llama-3.1-8b-instant&lt;/code&gt; instead of a 70B model. This cut our average cost-per-task by 31%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Micro-batching.&lt;/strong&gt; For task types where the model supports batch input, we accumulate messages in 100ms windows and send them as a single request. 40% reduction in per-call API overhead (connection setup, auth handshake, response parsing). Impossible to do cleanly in a synchronous request handler without blocking the caller.&lt;/p&gt;

&lt;p&gt;[!NOTE]&lt;br&gt;
$687/day sounds large. At 2M requests that's $0.00034 per request — roughly $20K/month. Running equivalent workloads on self-hosted GPU infrastructure (A100s, realistic utilization rates, ops overhead) would cost us approximately $2,100/day. The managed API is cheaper at our scale.&lt;/p&gt;
&lt;h2&gt;
  
  
  Before and After: Production Numbers
&lt;/h2&gt;

&lt;p&gt;We ran both architectures in parallel for 30 days — old sync on 20% of traffic, new event-driven on 80% — before cutting over completely.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Sync (before)&lt;/th&gt;
&lt;th&gt;Event-Driven (after)&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;P50 user-visible latency&lt;/td&gt;
&lt;td&gt;4.2 s&lt;/td&gt;
&lt;td&gt;0.8 s (202 + async push)&lt;/td&gt;
&lt;td&gt;−81%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P99 user-visible latency&lt;/td&gt;
&lt;td&gt;28.4 s&lt;/td&gt;
&lt;td&gt;1.1 s&lt;/td&gt;
&lt;td&gt;−96%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request timeout rate&lt;/td&gt;
&lt;td&gt;3.2%&lt;/td&gt;
&lt;td&gt;0.001%&lt;/td&gt;
&lt;td&gt;−99.97%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests dropped (unrecoverable)&lt;/td&gt;
&lt;td&gt;1.8%&lt;/td&gt;
&lt;td&gt;0.02%&lt;/td&gt;
&lt;td&gt;−99%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 1M requests&lt;/td&gt;
&lt;td&gt;$420&lt;/td&gt;
&lt;td&gt;$344&lt;/td&gt;
&lt;td&gt;−18%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On-call pages per week&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;−93%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The P99 number matters most. Users hitting a 28-second timeout perceived the feature as broken, even if only 3% of requests reached that threshold. With async dispatch and WebSocket delivery, P50 user-visible latency is under 1 second (the 202 response arrives instantly) and the result appears as a push notification when the LLM finishes.&lt;/p&gt;

&lt;p&gt;The 93% reduction in on-call pages was unexpected. Most of the 14 weekly pages were timeout-related cascades. When you remove synchronous coupling between the API layer and the LLM layer, most cascade failure modes disappear entirely.&lt;/p&gt;
&lt;h2&gt;
  
  
  When NOT to Go Event-Driven
&lt;/h2&gt;

&lt;p&gt;This architecture adds real operational complexity. It's not always the right answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stay synchronous when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The result must block the response.&lt;/strong&gt; Real-time payment validation, auth decisions, interactive AI chat where the user is watching a streaming response. If you can't send a 202, you need sync.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Volume is under ~10K requests/day.&lt;/strong&gt; The operational overhead of Kafka, consumer groups, DLQ management, and result delivery infrastructure doesn't pay off at low volume. A simple async HTTP call with a 30-second timeout and a Postgres-backed retry queue is the right choice.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You don't have result delivery infrastructure.&lt;/strong&gt; WebSockets, SSE, or polling endpoints are meaningful engineering work. If you're not willing to build that, your users will stare at a spinner indefinitely. "We'll add the result delivery later" becomes "we added Kafka but the feature still feels broken."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Your team is new to Kafka.&lt;/strong&gt; Consumer group rebalancing, partition assignment, exactly-once transactions, DLQ management — these are non-trivial operational concerns. Kafka is operationally complex when something goes wrong. Make sure your team has the skills before you take the dependency.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The decision framework:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A{Is LLM call in the\ncritical response path?} --&amp;gt;|Yes| B{Can user wait\n&amp;gt;2 seconds?}
    A --&amp;gt;|No, background work| E[Go event-driven]

    B --&amp;gt;|No| C[Stay synchronous\nuse streaming SSE]
    B --&amp;gt;|Yes| D{Volume &amp;gt; 50K/day?}

    D --&amp;gt;|No| F[Synchronous with\nretry queue in Postgres]
    D --&amp;gt;|Yes| G{Team has\nKafka experience?}

    G --&amp;gt;|No| H[Start sync, plan\nKafka migration]
    G --&amp;gt;|Yes| E

    style E fill:#065f46,color:#6ee7b7
    style C fill:#1e3a5f,color:#7dd3fc
    style F fill:#374151,color:#d1d5db&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  The Minimum Viable Starting Point
&lt;/h2&gt;

&lt;p&gt;If you're starting fresh and want to validate the pattern before building the full architecture, here's the minimum working setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# application.yml — minimum for correctness&lt;/span&gt;
&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kafka&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap-servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost:9092&lt;/span&gt;
    &lt;span class="na"&gt;consumer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;group-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-worker&lt;/span&gt;
      &lt;span class="na"&gt;auto-offset-reset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;earliest&lt;/span&gt;
      &lt;span class="na"&gt;enable-auto-commit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;      &lt;span class="c1"&gt;# never auto-commit&lt;/span&gt;
      &lt;span class="na"&gt;max-poll-interval-ms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;90000&lt;/span&gt;
    &lt;span class="na"&gt;listener&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ack-mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MANUAL_IMMEDIATE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Minimum viable consumer — add rate limiting, DLQ, and idempotency incrementally&lt;/span&gt;
&lt;span class="nd"&gt;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai.tasks"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ai-worker"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;consume&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Payload&lt;/span&gt; &lt;span class="nc"&gt;AITask&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Acknowledgment&lt;/span&gt; &lt;span class="n"&gt;ack&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AIResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;callLLM&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;resultTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.results"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;ack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acknowledge&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;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;"Task {} failed: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;taskId&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;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="c1"&gt;// Don't ack — Kafka redelivers&lt;/span&gt;
        &lt;span class="c1"&gt;// Add DLQ logic here after you've observed retry behavior in staging&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;Start here. Add the adaptive rate limiter when you first see 429s in production. Add DLQs when you see messages redelivered indefinitely. Add idempotency when you see duplicate results. Add distributed tracing when debugging becomes painful. The architecture I described above is 18 months of production failures distilled — you don't need all of it on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Synchronous LLM calls are fine until they're not.&lt;/strong&gt; The failure mode is sudden and cascading — build the escape hatch before you need it. The warning signs are rising P99 latency and rate limit errors.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kafka absorbs traffic spikes that would kill a synchronous system.&lt;/strong&gt; The queue is the buffer between your traffic patterns and your provider's rate limits. You can't add that buffer to a sync architecture without fundamentally restructuring it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Idempotency is non-negotiable.&lt;/strong&gt; At-least-once delivery is guaranteed by Kafka; idempotent consumers are your responsibility. An idempotency check at the top of every consumer is not optional overhead — it's correctness.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rate limiting belongs in the consumer, not the gateway.&lt;/strong&gt; Only the consumer knows what's actually in flight against the LLM provider. Gateway-level rate limiting gives you no visibility into queue depth.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token counts in every result event.&lt;/strong&gt; You cannot optimize what you cannot see. Per-model, per-task-type token counts are the only way to know which workloads are driving your LLM bill.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DLQs are not optional.&lt;/strong&gt; Every permanently failed task needs a destination. "Log it and move on" means you've silently dropped user work. DLQ + replay gives you a recovery path that sync architectures simply don't have.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The biggest win isn't latency — it's reliability.&lt;/strong&gt; P99 improvements are impressive. Getting paged 93% less often at 3AM is what actually changes how your team feels about owning the service.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The investment is real — probably 6–8 weeks of solid engineering to get this fully operational. In our case, it was worth it the first Saturday night we didn't get paged.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The next post in this series covers stateful aggregation with Kafka Streams — specifically windowed token-count aggregations for real-time cost alerts and per-tenant quota enforcement. If you're hitting a different failure mode than I described here, or building something similar, I'd love to hear about it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>backend</category>
      <category>llm</category>
    </item>
    <item>
      <title>Your Spring Boot App Starts in 3 Seconds. With GraalVM Native, It's 80ms.</title>
      <dc:creator>Avaneesh Yadav</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:16:18 +0000</pubDate>
      <link>https://dev.to/avaneeshyadav/your-spring-boot-app-starts-in-3-seconds-with-graalvm-native-its-80ms-3i90</link>
      <guid>https://dev.to/avaneeshyadav/your-spring-boot-app-starts-in-3-seconds-with-graalvm-native-its-80ms-3i90</guid>
      <description>&lt;p&gt;Every Spring Boot developer has watched the console scroll through startup lines and thought: &lt;em&gt;this feels slow&lt;/em&gt;. For a monolith on a beefy server, 3–5 seconds is fine. But when you're paying per-millisecond on AWS Lambda, scaling pods on Kubernetes, or trying to pass a 2-second health-check SLA, that startup time becomes a real cost.&lt;/p&gt;

&lt;p&gt;GraalVM Native Image solves this by compiling your entire application — Spring, your code, and the JDK — into a single native binary ahead of time. The result: sub-100ms startup, a fraction of the memory footprint, and a container image under 100MB.&lt;/p&gt;

&lt;p&gt;Spring Boot 3 added first-class native support. In 2026, the tooling has matured to the point where most real-world Spring Boot apps can go native without heroic effort. This post walks through what actually happens under the hood, the pitfalls that will bite you, and honest benchmarks so you know what you're getting.&lt;/p&gt;

&lt;h2&gt;
  
  
  How GraalVM Native Image Actually Works
&lt;/h2&gt;

&lt;p&gt;The JVM is a dynamic runtime. Classes are loaded lazily, reflection resolves types at runtime, proxies are generated on the fly, and the JIT compiler optimizes hot paths after they've been called thousands of times. All of that flexibility comes at a cost: startup.&lt;/p&gt;

&lt;p&gt;GraalVM's Native Image compiler inverts this model. At build time it performs a &lt;strong&gt;closed-world analysis&lt;/strong&gt;: starting from your &lt;code&gt;main&lt;/code&gt; method, it traces every reachable class, method, and field, and compiles everything into a native binary using ahead-of-time (AOT) compilation. Nothing is loaded lazily. The JIT is gone. What you get is a self-contained executable that starts like a Go binary.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    src["📄 Source Code (Java)"]
    compile["⚙️ javac Compile"]
    bytecode["📦 Bytecode (.class files)"]
    aot["🔍 Spring AOT Processor"]
    hints["📋 Reflection Hints + Proxy Configs"]
    native["🔨 GraalVM native-image"]
    binary["🚀 Native Binary (OS-specific)"]

    src --&amp;gt; compile --&amp;gt; bytecode
    bytecode --&amp;gt; aot
    aot --&amp;gt; hints
    bytecode --&amp;gt; native
    hints --&amp;gt; native
    native --&amp;gt; binary&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The key challenge: anything that relies on runtime dynamism — reflection, dynamic proxies, runtime resource loading — must be declared at build time so the compiler knows to include it. That's the source of most native-image pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring Boot 3's AOT Processing
&lt;/h2&gt;

&lt;p&gt;Spring Framework 5 and earlier were built on reflection everywhere. Spring Boot 3 (Spring Framework 6) was the first version designed with native in mind. It added an &lt;strong&gt;AOT engine&lt;/strong&gt; that runs at build time and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generates source code for bean definitions instead of registering them dynamically&lt;/li&gt;
&lt;li&gt;Pre-computes &lt;code&gt;@Configuration&lt;/code&gt; class processing&lt;/li&gt;
&lt;li&gt;Generates reflection hints for every Spring component&lt;/li&gt;
&lt;li&gt;Registers proxies for &lt;code&gt;@Transactional&lt;/code&gt;, &lt;code&gt;@Async&lt;/code&gt;, &lt;code&gt;@Cacheable&lt;/code&gt;, and other AOP annotations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you run &lt;code&gt;spring-boot:build-image&lt;/code&gt; with the &lt;code&gt;native&lt;/code&gt; profile, Spring's AOT processor runs first, then its output feeds into the GraalVM compiler. You get native compatibility essentially for free on the Spring layer — the hard part is your own code and third-party libraries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up the Build
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;GraalVM for JDK 21 or later (or use the &lt;a href="https://graalvm.github.io/native-build-tools/latest/" rel="noopener noreferrer"&gt;GraalVM Maven Toolchain&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Spring Boot 3.2+ (3.3+ recommended)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;spring-boot-starter&lt;/code&gt; parent or &lt;code&gt;native-maven-plugin&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Maven Setup
&lt;/h3&gt;

&lt;p&gt;Add the &lt;code&gt;native&lt;/code&gt; profile to your &lt;code&gt;pom.xml&lt;/code&gt;:&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;profiles&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;profile&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;id&amp;gt;&lt;/span&gt;native&lt;span class="nt"&gt;&amp;lt;/id&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;build&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;plugins&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;plugin&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.graalvm.buildtools&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;native-maven-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;extensions&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/extensions&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;executions&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;execution&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;id&amp;gt;&lt;/span&gt;build-native&lt;span class="nt"&gt;&amp;lt;/id&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;goals&amp;gt;&amp;lt;goal&amp;gt;&lt;/span&gt;compile-no-fork&lt;span class="nt"&gt;&amp;lt;/goal&amp;gt;&amp;lt;/goals&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;phase&amp;gt;&lt;/span&gt;package&lt;span class="nt"&gt;&amp;lt;/phase&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/execution&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/executions&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;configuration&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;imageName&amp;gt;&lt;/span&gt;${project.artifactId}&lt;span class="nt"&gt;&amp;lt;/imageName&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;buildArgs&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;buildArg&amp;gt;&lt;/span&gt;--no-fallback&lt;span class="nt"&gt;&amp;lt;/buildArg&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;buildArg&amp;gt;&lt;/span&gt;-O2&lt;span class="nt"&gt;&amp;lt;/buildArg&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/buildArgs&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/plugins&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/build&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/profile&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/profiles&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build the native binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./mvnw &lt;span class="nt"&gt;-Pnative&lt;/span&gt; native:compile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build a container image (no GraalVM needed on the host — uses Buildpacks):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./mvnw &lt;span class="nt"&gt;-Pnative&lt;/span&gt; spring-boot:build-image
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Gradle Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;plugins&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="s1"&gt;'org.graalvm.buildtools.native'&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="s1"&gt;'0.10.3'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;graalvmNative&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;binaries&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;main&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;imageName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'my-service'&lt;/span&gt;
      &lt;span class="n"&gt;buildArgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'--no-fallback'&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./gradlew nativeCompile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Real Benchmark Numbers
&lt;/h2&gt;

&lt;p&gt;I compiled a realistic Spring Boot service — REST endpoints, JPA with PostgreSQL, Spring Security, a scheduled job, and Redis caching — and measured cold start across modes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Startup Time&lt;/th&gt;
&lt;th&gt;RSS Memory (idle)&lt;/th&gt;
&lt;th&gt;Container Image Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JVM (Java 21)&lt;/td&gt;
&lt;td&gt;3.2s&lt;/td&gt;
&lt;td&gt;310 MB&lt;/td&gt;
&lt;td&gt;280 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JVM + CDS&lt;/td&gt;
&lt;td&gt;1.8s&lt;/td&gt;
&lt;td&gt;270 MB&lt;/td&gt;
&lt;td&gt;285 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native Image&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;78ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;62 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;98 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;These numbers are real. RSS will vary based on your workload — once the app handles traffic, the JVM typically catches up on throughput due to JIT optimization. But for startup and idle memory, native wins by a factor of 4–5x.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;strong&gt;container image size&lt;/strong&gt; difference matters most in Kubernetes. Pulling a 98 MB image on a fresh node is fast. Pulling 280 MB when you're trying to scale up fast under traffic is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pitfalls (And How to Fix Them)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Reflection That You Don't Know About
&lt;/h3&gt;

&lt;p&gt;The most common failure mode: a &lt;code&gt;ClassNotFoundException&lt;/code&gt; or &lt;code&gt;NoSuchMethodException&lt;/code&gt; at runtime in your native binary, which ran fine on the JVM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens:&lt;/strong&gt; You (or a library) called &lt;code&gt;Class.forName("com.example.SomeThing")&lt;/code&gt; or &lt;code&gt;ObjectMapper.readValue(json, SomeThing.class)&lt;/code&gt;. The closed-world compiler didn't see that reference statically, so it excluded the class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Add a &lt;code&gt;reflect-config.json&lt;/code&gt; hint, or use Spring's &lt;code&gt;@RegisterReflectionForBinding&lt;/code&gt; annotation:&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;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@RegisterReflectionForBinding&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt;&lt;span class="nc"&gt;OrderDTO&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="nc"&gt;PaymentDTO&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NativeHintsConfig&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;Or with the programmatic hints API:&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;@Component&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;MyHints&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;RuntimeHintsRegistrar&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;@Override&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;registerHints&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RuntimeHints&lt;/span&gt; &lt;span class="n"&gt;hints&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ClassLoader&lt;/span&gt; &lt;span class="n"&gt;classLoader&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;hints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reflection&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderDTO&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="nc"&gt;MemberCategory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INVOKE_DECLARED_CONSTRUCTORS&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                    &lt;span class="nc"&gt;MemberCategory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DECLARED_FIELDS&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;Then annotate your main class:&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;@SpringBootApplication&lt;/span&gt;
&lt;span class="nd"&gt;@ImportRuntimeHints&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MyHints&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Application&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;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;SpringApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Application&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="n"&gt;args&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;h3&gt;
  
  
  2. Dynamic Proxies
&lt;/h3&gt;

&lt;p&gt;If you use &lt;code&gt;java.lang.reflect.Proxy&lt;/code&gt; or Mockito-style dynamic proxies in production code (not just tests), they must be declared upfront.&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="n"&gt;hints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;proxies&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;registerJdkProxy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MyService&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="nc"&gt;MyOtherInterface&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring automatically registers proxies for &lt;code&gt;@Transactional&lt;/code&gt;, &lt;code&gt;@Async&lt;/code&gt;, and AOP-annotated beans. But if you create proxies manually, you must register them.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Resources Not Found
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;getClass().getResourceAsStream("/templates/email.html")&lt;/code&gt; works on the JVM because the classpath is there at runtime. In a native binary, resources must be explicitly included.&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="n"&gt;hints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;registerPattern&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"templates/*"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or in &lt;code&gt;application.properties&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.aot.hint.resources.patterns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;templates/*,messages/**&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Logback / SLF4J Configuration
&lt;/h3&gt;

&lt;p&gt;Logback's XML parsing uses reflection internally. Use Spring Boot's auto-configured logging (it already has native hints) and avoid custom &lt;code&gt;logback.xml&lt;/code&gt; with dynamic &lt;code&gt;&amp;lt;if&amp;gt;&lt;/code&gt; conditions.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Jackson and Custom Serializers
&lt;/h3&gt;

&lt;p&gt;Jackson works well natively when you use &lt;code&gt;@JsonProperty&lt;/code&gt;, &lt;code&gt;@JsonSerialize&lt;/code&gt;, and standard annotations. Problems arise with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TypeReference&amp;lt;List&amp;lt;MyDTO&amp;gt;&amp;gt;&lt;/code&gt; anonymous classes&lt;/li&gt;
&lt;li&gt;Polymorphic types with &lt;code&gt;@JsonTypeInfo&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For polymorphic types, register them explicitly:&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="n"&gt;hints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reflection&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Animal&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="nc"&gt;MemberCategory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INVOKE_DECLARED_CONSTRUCTORS&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MemberCategory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DECLARED_FIELDS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dog&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="nc"&gt;MemberCategory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INVOKE_DECLARED_CONSTRUCTORS&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MemberCategory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DECLARED_FIELDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing Your Native Build Before Committing
&lt;/h2&gt;

&lt;p&gt;The worst experience is discovering native failures only after a 5-minute &lt;code&gt;native-image&lt;/code&gt; compile. Use the GraalVM agent on the JVM first — it traces all reflective accesses and generates the config files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;java &lt;span class="nt"&gt;-agentlib&lt;/span&gt;:native-image-agent&lt;span class="o"&gt;=&lt;/span&gt;config-output-dir&lt;span class="o"&gt;=&lt;/span&gt;src/main/resources/META-INF/native-image &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-jar&lt;/span&gt; target/myapp.jar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run your app through all code paths (or better, run your integration test suite against it), then stop the app. The agent writes &lt;code&gt;reflect-config.json&lt;/code&gt;, &lt;code&gt;proxy-config.json&lt;/code&gt;, and &lt;code&gt;resource-config.json&lt;/code&gt; into the output directory. Commit these files — GraalVM picks them up automatically.&lt;/p&gt;

&lt;p&gt;You can also use Spring's Native Test support to validate hints without a full native compile:&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;@SpringBootTest&lt;/span&gt;
&lt;span class="nd"&gt;@TestPropertySource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"spring.aot.enabled=true"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NativeHintsValidationTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;@Test&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;contextLoads&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What You Give Up
&lt;/h2&gt;

&lt;p&gt;Native Image is not free. Know the trade-offs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;JVM&lt;/th&gt;
&lt;th&gt;Native&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Build time&lt;/td&gt;
&lt;td&gt;30–60s&lt;/td&gt;
&lt;td&gt;3–8 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput under load&lt;/td&gt;
&lt;td&gt;Higher (JIT)&lt;/td&gt;
&lt;td&gt;Slightly lower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic class loading&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reflection&lt;/td&gt;
&lt;td&gt;Unrestricted&lt;/td&gt;
&lt;td&gt;Declared only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JVMTI / profiling agents&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debuggability&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Basic (&lt;code&gt;-g&lt;/code&gt; flag)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Library compatibility&lt;/td&gt;
&lt;td&gt;Universal&lt;/td&gt;
&lt;td&gt;Most, not all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Throughput:&lt;/strong&gt; Under sustained load, JIT-compiled code is often faster than native because JIT can optimize based on runtime behavior (branch prediction, inlining). Native binaries start faster and use less memory but may have slightly lower peak throughput for CPU-intensive workloads. For I/O-bound services (which most Spring Boot apps are), the difference is negligible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build time:&lt;/strong&gt; A 5-minute build is annoying in local dev. Keep native as a CI/CD step, not part of your inner dev loop. Run &lt;code&gt;./mvnw spring-boot:run&lt;/code&gt; locally, build native only in the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Native Is Worth It
&lt;/h2&gt;

&lt;p&gt;Go native when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serverless / FaaS:&lt;/strong&gt; AWS Lambda cold starts are a real cost. 78ms vs 3.2s is the difference between a usable API and an unusable one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes burst scaling:&lt;/strong&gt; Pods that start in under a second can scale to handle traffic spikes. 3-second startup means you're dropping requests while pods warm up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory-constrained environments:&lt;/strong&gt; 62 MB RSS vs 310 MB means you can run 5x more instances on the same node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI tools built in Java:&lt;/strong&gt; A native CLI binary that starts instantly feels as good as a Go or Rust CLI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stay on the JVM when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your app uses libraries without native support (check the &lt;a href="https://github.com/oracle/graalvm-reachability-metadata" rel="noopener noreferrer"&gt;GraalVM Reachability Metadata Repository&lt;/a&gt; first)&lt;/li&gt;
&lt;li&gt;You rely heavily on runtime class generation (e.g., ByteBuddy, Javassist in non-Spring contexts)&lt;/li&gt;
&lt;li&gt;Peak throughput matters more than startup (batch processing, number crunching)&lt;/li&gt;
&lt;li&gt;Your team isn't ready to deal with native build failures in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  CI/CD Pipeline
&lt;/h2&gt;

&lt;p&gt;Keep native builds in a separate job so they don't slow down your PR feedback loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/build.yml&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-java@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;java-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;21'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;distribution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;temurin'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./mvnw verify&lt;/span&gt;   &lt;span class="c1"&gt;# fast: compile + test on JVM&lt;/span&gt;

  &lt;span class="na"&gt;native&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main'&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;graalvm/setup-graalvm@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;java-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;21'&lt;/span&gt;
          &lt;span class="na"&gt;distribution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;graalvm'&lt;/span&gt;
          &lt;span class="na"&gt;native-image-job-reports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./mvnw -Pnative native:compile -DskipTests&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./mvnw -Pnative spring-boot:build-image -DskipTests&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker push myregistry/myapp:${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;native-image-job-reports: 'true'&lt;/code&gt; flag in the GraalVM action gives you a detailed report in the GitHub Actions summary showing which classes were included, missing hints, and build time breakdown — useful when debugging failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Library Support in 2026
&lt;/h2&gt;

&lt;p&gt;The ecosystem has largely caught up. Most common Spring Boot dependencies have native support via the GraalVM Reachability Metadata Repository:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Native Support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spring Data JPA / Hibernate&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring Security&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring Cache + Redis&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flyway / Liquibase&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Micrometer + Prometheus&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MapStruct&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lombok&lt;/td&gt;
&lt;td&gt;✅ Full (compile-time only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jackson&lt;/td&gt;
&lt;td&gt;✅ Full with hints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gRPC (grpc-spring-boot-starter)&lt;/td&gt;
&lt;td&gt;✅ Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testcontainers&lt;/td&gt;
&lt;td&gt;⚠️ Test-only, not in native binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mockito&lt;/td&gt;
&lt;td&gt;❌ Not for production use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ByteBuddy (direct use)&lt;/td&gt;
&lt;td&gt;⚠️ Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If a library you need isn't on the list, check if it provides its own &lt;code&gt;native-image.properties&lt;/code&gt; or if there's a community contribution in the Reachability Metadata Repository. Alternatively, you can contribute hints upstream — the process is straightforward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;GraalVM Native Image + Spring Boot 3 is production-ready in 2026. The tooling has matured, the Spring AOT processor handles 80% of the work automatically, and most real-world Spring Boot apps can go native with a manageable amount of hint configuration.&lt;/p&gt;

&lt;p&gt;The build time penalty (5–8 minutes) is real, but it belongs in CI — not in your dev loop. The payoff — 40x faster startup, 5x lower memory, and images under 100MB — is a genuine competitive advantage for teams running containerized services.&lt;/p&gt;

&lt;p&gt;Start with one service. Run the GraalVM agent against your integration tests, commit the generated hints, add the native build job to CI. If it works (it probably will), you have a blueprint to apply to the rest of your stack.&lt;/p&gt;

&lt;p&gt;The hardest part is getting through the first build error. After that, it's just plumbing.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>java</category>
      <category>performance</category>
    </item>
    <item>
      <title>10 Spring Boot Mistakes That Silently Kill Production Apps</title>
      <dc:creator>Avaneesh Yadav</dc:creator>
      <pubDate>Mon, 31 Aug 2026 10:26:17 +0000</pubDate>
      <link>https://dev.to/avaneeshyadav/10-spring-boot-mistakes-that-silently-kill-production-apps-30bl</link>
      <guid>https://dev.to/avaneeshyadav/10-spring-boot-mistakes-that-silently-kill-production-apps-30bl</guid>
      <description>&lt;p&gt;Most Spring Boot production incidents don't come from exotic bugs. They come from the same ten patterns, repeated across codebases, that work fine under light load and silently degrade as traffic grows.&lt;/p&gt;

&lt;p&gt;This guide covers the anti-patterns that production Java systems hit most often — with the exact code that causes them, why they fail, and what to write instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. @Transactional on Private Methods
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mistake:&lt;/strong&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;@Service&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;OrderService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// This @Transactional does NOTHING.&lt;/span&gt;
    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;processPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// changes to order are NOT in a transaction&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;submitOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;processPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// direct call, bypasses proxy&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;strong&gt;Why it fails:&lt;/strong&gt; Spring's &lt;code&gt;@Transactional&lt;/code&gt; works through a proxy that wraps the bean. When you call a private method — or any method from within the same class — you bypass the proxy and call the raw object directly. No proxy → no transaction. Exceptions don't roll back. Data writes commit even when they shouldn't.&lt;/p&gt;

&lt;p&gt;This is the most common Spring transactional bug. It compiles and passes unit tests. It fails silently in production when a mid-transaction exception leaves the database in a partially-written state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&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;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&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;OrderService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;PaymentService&lt;/span&gt; &lt;span class="n"&gt;paymentService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// separate @Service&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;submitOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;paymentService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;processPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// goes through Spring proxy&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Service&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;PaymentService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt; &lt;span class="c1"&gt;// works because external call goes through proxy&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;processPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&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="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;If refactoring to a separate class isn't practical, inject &lt;code&gt;self&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;@Service&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;OrderService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Spring injects the proxied version&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;submitOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;processPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// goes through proxy&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;processPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&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="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;strong&gt;Catch it early:&lt;/strong&gt; Enable Spring's proxy debug logging (&lt;code&gt;logging.level.org.springframework.aop=DEBUG&lt;/code&gt;) in a staging environment and verify transactions are opening where expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The N+1 Query Problem
&lt;/h2&gt;

&lt;p&gt;This is the single most common performance issue in JPA-based Spring Boot applications. A page that renders in 50ms under load testing collapses to 4 seconds in production because the dataset grew.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mistake:&lt;/strong&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="c1"&gt;// Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Entity&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;Order&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FetchType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LAZY&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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LineItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lineItems&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Service&lt;/span&gt;
&lt;span class="kd"&gt;public&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;OrderSummary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getOrders&lt;/span&gt;&lt;span class="o"&gt;()&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;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findAll&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 1 query&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;orders&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;o&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;OrderSummary&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLineItems&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="c1"&gt;// N queries&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&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;For 200 orders: 1 query to fetch orders + 200 queries to fetch each order's line items = 201 queries. At 1000 orders it's 1001 queries. Most ORMs make this invisible — the queries happen lazily, inside the stream, with no stack frame pointing to the cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to detect it:&lt;/strong&gt; Enable query logging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jpa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;show-sql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;hibernate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;format_sql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;org.hibernate.SQL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DEBUG&lt;/span&gt;
    &lt;span class="na"&gt;org.hibernate.orm.jdbc.bind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TRACE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see the same query repeated in a loop with different ID parameters, it's N+1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix — JOIN FETCH:&lt;/strong&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="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&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;Order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT o FROM Order o LEFT JOIN FETCH o.lineItems WHERE o.status = :status"&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;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByStatusWithItems&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;"status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;status&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 query. Fetches orders and their line items in a single JOIN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix — Projections (when you only need some fields):&lt;/strong&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="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderSummaryView&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getOrderId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="nf"&gt;getTotal&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;getItemCount&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;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&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;Order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderSummaryView&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;status&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;Projections let Spring Data generate a SELECT with only the columns you need — no entity materialization, no lazy loading risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix — @EntityGraph:&lt;/strong&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;@EntityGraph&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributePaths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"lineItems"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"lineItems.product"&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;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Declarative JOIN FETCH without a JPQL query. Cleaner for simple cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Loading Entities for Count/Existence Checks
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Loads the entire entity from DB just to check if it exists&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;isPresent&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Loads all orders just to count them&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findAll&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are SELECT * queries that transfer full row data across the network, materialize Java objects in heap, then throw most of it away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&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="c1"&gt;// Existence — single indexed column lookup, no data transfer&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&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="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Count — aggregation runs on the DB side&lt;/span&gt;
&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Conditional count&lt;/span&gt;
&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;pendingCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;countByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For complex conditions:&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="s"&gt;"SELECT COUNT(o) FROM Order o WHERE o.status = :status AND o.createdAt &amp;gt; :since"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="nf"&gt;countRecentByStatus&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;"status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;status&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;"since"&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;since&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Misconfigured Hikari Connection Pool
&lt;/h2&gt;

&lt;p&gt;HikariCP ships with &lt;code&gt;maximum-pool-size=10&lt;/code&gt;. Under low load this is invisible. Under real traffic it causes connection wait times that cascade into request timeouts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Default — often wrong for production&lt;/span&gt;
&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;datasource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hikari&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;maximum-pool-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;  &lt;span class="c1"&gt;# too low for most services&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Symptoms:&lt;/strong&gt; Requests succeed but take 200-500ms longer than expected. Actuator metrics show &lt;code&gt;hikaricp.connections.pending&lt;/code&gt; &amp;gt; 0 regularly. Logs show &lt;code&gt;HikariPool - Connection is not available, request timed out&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix — size for your thread model:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;datasource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hikari&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# For virtual threads: connections = (number of cores * 2) is a starting point&lt;/span&gt;
      &lt;span class="c1"&gt;# For platform threads: connections = thread pool size&lt;/span&gt;
      &lt;span class="na"&gt;maximum-pool-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
      &lt;span class="na"&gt;minimum-idle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;connection-timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;    &lt;span class="c1"&gt;# fail fast — 3s, not the default 30s&lt;/span&gt;
      &lt;span class="na"&gt;idle-timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600000&lt;/span&gt;        &lt;span class="c1"&gt;# 10 min&lt;/span&gt;
      &lt;span class="na"&gt;max-lifetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1800000&lt;/span&gt;       &lt;span class="c1"&gt;# 30 min — rotate before DB kills idle connections&lt;/span&gt;
      &lt;span class="na"&gt;keepalive-time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60000&lt;/span&gt;       &lt;span class="c1"&gt;# prevent firewall from killing idle connections&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expose pool metrics:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;hikaricp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then monitor &lt;code&gt;hikaricp.connections.pending&lt;/code&gt; and &lt;code&gt;hikaricp.connections.acquire&lt;/code&gt; p99. If pending &amp;gt; 0 for more than brief spikes, increase the pool.&lt;/p&gt;

&lt;p&gt;[!NOTE]&lt;br&gt;
With virtual threads (Spring Boot 3.2+, &lt;code&gt;spring.threads.virtual.enabled=true&lt;/code&gt;), the optimal pool size is lower than with platform threads — virtual threads park during I/O so fewer connections handle more concurrent requests. Start at 2× core count and tune from metrics.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. @Transactional(readOnly = true) Left Off Read Queries
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Missing readOnly — Spring opens a full R/W transaction&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&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;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findRecentOrders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;since&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="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByCreatedAtAfter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;atStartOfDay&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;readOnly = true&lt;/code&gt; on a read-only transaction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tells Hibernate to skip dirty checking (no snapshot comparison at flush time)&lt;/li&gt;
&lt;li&gt;Lets the JPA provider skip the write-ahead log&lt;/li&gt;
&lt;li&gt;Some databases route read-only transactions to read replicas automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&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;@Transactional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readOnly&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="kd"&gt;public&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;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findRecentOrders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;since&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="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByCreatedAtAfter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;atStartOfDay&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;Low effort, measurable improvement under read-heavy load. On services with 80% read traffic, this alone reduces database write-lock contention.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Missing Database Indexes on JPA Foreign Keys
&lt;/h2&gt;

&lt;p&gt;JPA doesn't automatically create indexes on foreign key columns. It creates the constraint, not the index. This causes full table scans on every JOIN against a large table.&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;@Entity&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;LineItem&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@ManyToOne&lt;/span&gt;
    &lt;span class="nd"&gt;@JoinColumn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"order_id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// creates FK constraint, NOT an index&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&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;Finding all line items for an order does &lt;code&gt;SELECT * FROM line_item WHERE order_id = ?&lt;/code&gt;. Without an index on &lt;code&gt;order_id&lt;/code&gt;, this is a full scan of the entire &lt;code&gt;line_item&lt;/code&gt; table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix — declare the index explicitly:&lt;/strong&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;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"line_item"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;@Index&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"idx_line_item_order_id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columnList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"order_id"&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;LineItem&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@ManyToOne&lt;/span&gt;
    &lt;span class="nd"&gt;@JoinColumn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"order_id"&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;Order&lt;/span&gt; &lt;span class="n"&gt;order&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;Or via Liquibase/Flyway (preferred for production — schema migrations in code):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- V3__add_line_item_order_index.sql&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_line_item_order_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;line_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Finding missing indexes:&lt;/strong&gt; Run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on your slowest queries. Any &lt;code&gt;Seq Scan&lt;/code&gt; on a large table that filters by a FK column is a missing index candidate.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Leaking Hibernate Sessions Into Jackson Serialization
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&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;Order&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FetchType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LAZY&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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LineItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lineItems&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Controller — Open Session in View is ON by default&lt;/span&gt;
&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders/{id}"&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;Order&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&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="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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="c1"&gt;// Jackson serializes Order → touches lineItems → Hibernate loads lazily&lt;/span&gt;
    &lt;span class="c1"&gt;// If session is closed: LazyInitializationException&lt;/span&gt;
    &lt;span class="c1"&gt;// If session is open (OSIV): unexpected extra query per request&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring Boot enables Open Session in View (OSIV) by default. This keeps the Hibernate session open through the entire request, including serialization. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lazy collections load silently during JSON serialization (N+1 hidden in Jackson)&lt;/li&gt;
&lt;li&gt;Session-per-request model doesn't compose with virtual threads&lt;/li&gt;
&lt;li&gt;Database connections held for the full request lifecycle, not just the DB operation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The fix — disable OSIV, use DTOs:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jpa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;open-in-view&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# disable OSIV&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fetch everything you need while the session is open&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readOnly&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="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;OrderResponse&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByIdWithItems&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;OrderNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="c1"&gt;// Map to a DTO — no Hibernate proxy, safe to serialize&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OrderResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&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="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;OrderResponse&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;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;total&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;LineItemResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;OrderResponse&lt;/span&gt; &lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;o&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTotal&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLineItems&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="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;LineItemResponse:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toList&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Unbounded &lt;a class="mentioned-user" href="https://dev.to/async"&gt;@async&lt;/a&gt; Thread Pools
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableAsync&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;AsyncConfig&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AsyncConfigurer&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// No custom config — Spring defaults to SimpleAsyncTaskExecutor&lt;/span&gt;
    &lt;span class="c1"&gt;// SimpleAsyncTaskExecutor creates a new thread per task, no limit&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Service&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;NotificationService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Async&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&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;to&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;subject&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&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;&lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt; (the default when no pool is configured) creates a new thread per task with no upper bound. Under sustained load this spins up thousands of threads and OOMs the service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix — explicit bounded pool:&lt;/strong&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;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableAsync&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;AsyncConfig&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AsyncConfigurer&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Executor&lt;/span&gt; &lt;span class="nf"&gt;getAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt; &lt;span class="n"&gt;executor&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;ThreadPoolTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCorePoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMaxPoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueueCapacity&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="c1"&gt;// tasks queue before rejecting&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setThreadNamePrefix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"async-"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRejectedExecutionHandler&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;ThreadPoolExecutor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CallerRunsPolicy&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;executor&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;&lt;code&gt;CallerRunsPolicy&lt;/code&gt; is usually the right rejection handler for background tasks — it runs the task on the caller's thread rather than throwing. This creates natural backpressure: if the async pool is saturated, the calling thread slows down instead of the service crashing.&lt;/p&gt;

&lt;p&gt;For virtual threads (Java 21+):&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;@Bean&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Executor&lt;/span&gt; &lt;span class="nf"&gt;asyncExecutor&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;Executors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newVirtualThreadPerTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// unbounded, but virtual threads are cheap&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Virtual thread executors are safe to make unbounded because virtual threads don't block OS threads during I/O and cost ~1KB each.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Exception Swallowing in Background Jobs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&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;ReconciliationJob&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cron&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0 0 2 * * *"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;reconcile&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;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;"Reconciliation failed"&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="c1"&gt;// returns normally — scheduler sees success&lt;/span&gt;
            &lt;span class="c1"&gt;// no alert, no retry, no escalation&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;The scheduler sees this method return without exception and marks it successful. The error is in the log. Without a log alert configured, the failure is invisible. Reconciliation silently stops running.&lt;/p&gt;

&lt;p&gt;This pattern — catch, log, swallow — is how data inconsistencies accumulate over months without anyone noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix — let exceptions propagate, or use structured alerting:&lt;/strong&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;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cron&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0 0 2 * * *"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;reconcile&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="c1"&gt;// Send an alert before rethrowing&lt;/span&gt;
        &lt;span class="n"&gt;alertService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendAlert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Reconciliation failed: "&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;getMessage&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;AlertSeverity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HIGH&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"job.reconciliation.failure"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;increment&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;RuntimeException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Scheduled reconciliation failed"&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="c1"&gt;// Spring will log this as an error — also visible in Actuator health&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;Or better — use Temporal or Spring Batch for jobs that need fault tolerance and visibility (see the &lt;a href="https://buildingai.in/blog/temporal-replace-cron-spring-batch" rel="noopener noreferrer"&gt;Temporal guide&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Logging in the Hot Path With String Concatenation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Evaluated on every call, even when DEBUG logging is off&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;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Processing order "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" for customer "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&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;+&lt;/code&gt; string concatenation creates intermediate String objects on every call. At 10,000 requests/minute, even a disabled DEBUG statement that runs on every request produces garbage that GC must collect, adding latency spikes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix — parameterized logging:&lt;/strong&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="c1"&gt;// SLF4J defers evaluation — no string created if DEBUG is off&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;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Processing order {} for customer {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;{}&lt;/code&gt; placeholders are only evaluated if the logger is actually at DEBUG level. Zero allocation in production where DEBUG is off.&lt;/p&gt;

&lt;p&gt;For complex log objects that are expensive to produce:&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="c1"&gt;// Supplier form — only called if DEBUG is on&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;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order state: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;computeExpensiveOrderDump&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Production Checklist
&lt;/h2&gt;

&lt;p&gt;Before a Spring Boot service goes to production, run through this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Grep for &lt;code&gt;@Transactional&lt;/code&gt; on private methods — fix any found&lt;/li&gt;
&lt;li&gt;[ ] Run queries against staging data at 10× production volume — look for N+1 patterns in query logs&lt;/li&gt;
&lt;li&gt;[ ] Disable OSIV (&lt;code&gt;spring.jpa.open-in-view: false&lt;/code&gt;) and fix resulting &lt;code&gt;LazyInitializationException&lt;/code&gt;s&lt;/li&gt;
&lt;li&gt;[ ] Run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on your top 10 slowest queries — add missing indexes&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;@Transactional(readOnly = true)&lt;/code&gt; to all read-only service methods&lt;/li&gt;
&lt;li&gt;[ ] Verify Hikari pool size matches your threading model — expose pool metrics in Actuator&lt;/li&gt;
&lt;li&gt;[ ] Configure a named, bounded &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; for &lt;code&gt;@Async&lt;/code&gt; tasks&lt;/li&gt;
&lt;li&gt;[ ] Verify all &lt;code&gt;@Scheduled&lt;/code&gt; jobs either let exceptions propagate or send alerts before swallowing&lt;/li&gt;
&lt;li&gt;[ ] Replace &lt;code&gt;log.debug("text" + var)&lt;/code&gt; with parameterized &lt;code&gt;log.debug("text {}", var)&lt;/code&gt; in hot paths&lt;/li&gt;
&lt;li&gt;[ ] Set &lt;code&gt;management.health.db.enabled=true&lt;/code&gt; and test liveness/readiness probes
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph LR
    A[Spring Boot Service] --&amp;gt; B{Code Review}
    B --&amp;gt; C[Transaction Check]
    B --&amp;gt; D[Query Analysis]
    B --&amp;gt; E[Pool Config]
    B --&amp;gt; F[Async Config]
    C --&amp;gt; G[No private @Transactional]
    D --&amp;gt; H[No N+1 — JOINs or projections]
    D --&amp;gt; I[Indexes on FK columns]
    E --&amp;gt; J[Bounded Hikari pool]
    E --&amp;gt; K[Pool metrics exposed]
    F --&amp;gt; L[Bounded executor configured]
    G --&amp;gt; M[Production Ready]
    H --&amp;gt; M
    I --&amp;gt; M
    J --&amp;gt; M
    K --&amp;gt; M
    L --&amp;gt; M&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Catching These Before Production
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Static analysis.&lt;/strong&gt; SpotBugs + find-sec-bugs catches some of these (including the &lt;code&gt;@Transactional&lt;/code&gt; private method issue). Add it to your Maven/Gradle build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query logging in staging.&lt;/strong&gt; Enable &lt;code&gt;org.hibernate.SQL: DEBUG&lt;/code&gt; in your staging environment against a dataset that's at least 10% of production volume. Any N+1 pattern shows up as repeated identical queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load testing.&lt;/strong&gt; k6, Gatling, or even &lt;code&gt;ab&lt;/code&gt; at 2× expected peak. Watch HikariCP's &lt;code&gt;connections.pending&lt;/code&gt;, Hibernate's &lt;code&gt;queries.execution&lt;/code&gt; p99, and JVM GC pause time. Spikes in any of these under load point to one of the issues above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production metrics.&lt;/strong&gt; Every Spring Boot 3 service should export to an observability backend. Dashboards to have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DB connection pool saturation (&lt;code&gt;hikaricp.connections.pending&lt;/code&gt; &amp;gt; 0)&lt;/li&gt;
&lt;li&gt;Slow query p99 (&amp;gt; 100ms threshold)&lt;/li&gt;
&lt;li&gt;GC pause duration (&amp;gt; 50ms threshold)&lt;/li&gt;
&lt;li&gt;Heap utilization trend (monotonic growth = memory leak)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These patterns aren't obscure. They appear in production codebases at companies of every size, written by engineers who knew the frameworks well. The difference between a service that holds up under load and one that requires an incident at 2 AM is usually caught before deployment, not after.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Spotted one of these in your codebase? Or have a production anti-pattern that isn't on this list? Find me on &lt;a href="https://linkedin.com/in/avaneeshyadav" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; — the list grows from war stories.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Java Developer Roadmap 2026: What to Learn, What to Skip</title>
      <dc:creator>Avaneesh Yadav</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:26:30 +0000</pubDate>
      <link>https://dev.to/avaneeshyadav/java-developer-roadmap-2026-what-to-learn-what-to-skip-3l57</link>
      <guid>https://dev.to/avaneeshyadav/java-developer-roadmap-2026-what-to-learn-what-to-skip-3l57</guid>
      <description>&lt;p&gt;Java is having its most interesting decade in twenty years. Virtual threads changed the concurrency model. Records and sealed classes modernized the type system. Pattern matching made the language feel genuinely expressive. And Spring AI put enterprise Java at the center of the AI engineering conversation.&lt;/p&gt;

&lt;p&gt;If you're a Java developer trying to figure out what actually matters to learn in 2026 — and what to stop spending time on — this is the guide. No paid course recommendations. No sponsored rankings. Just what's worth your time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Big Picture: What Changed
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph TD
    A[Java Developer 2026] --&amp;gt; B[Core Language]
    A --&amp;gt; C[Concurrency]
    A --&amp;gt; D[Frameworks]
    A --&amp;gt; E[AI Integration]
    A --&amp;gt; F[Cloud Native]

    B --&amp;gt; B1[Java 21-24 Features]
    B --&amp;gt; B2[Records and Sealed Classes]
    B --&amp;gt; B3[Pattern Matching]

    C --&amp;gt; C1[Virtual Threads]
    C --&amp;gt; C2[Structured Concurrency]
    C --&amp;gt; C3[Scoped Values]

    D --&amp;gt; D1[Spring Boot 3.x]
    D --&amp;gt; D2[Spring Security 6]
    D --&amp;gt; D3[Spring Data]

    E --&amp;gt; E1[Spring AI]
    E --&amp;gt; E2[LangChain4j]
    E --&amp;gt; E3[RAG Patterns]

    F --&amp;gt; F1[Docker and Kubernetes]
    F --&amp;gt; F2[GraalVM Native]
    F --&amp;gt; F3[OpenTelemetry]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  1. Core Java — The Non-Negotiables
&lt;/h2&gt;

&lt;p&gt;Java 21 was the LTS that mattered. Java 24 refined it further. Here's what every Java developer needs to know cold in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  Records (Java 16+, now essential)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: 40-line POJO with constructor, getters, equals, hashCode, toString&lt;/span&gt;
&lt;span class="c1"&gt;// After:&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;OrderSummary&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;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

&lt;span class="c1"&gt;// Pattern matching in switch — clean, exhaustive&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderSummary&lt;/span&gt; &lt;span class="n"&gt;order&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;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&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="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;PENDING&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Awaiting payment"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;PAID&lt;/span&gt;     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Processing — total: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;SHIPPED&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"On the way"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;DELIVERED&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Delivered"&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;Records are not just syntax sugar. They signal immutability, make DTOs and value objects composable, and pair naturally with sealed classes for algebraic data types.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sealed Classes + Pattern Matching
&lt;/h3&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;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;
    &lt;span class="n"&gt;permits&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Success&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Failure&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Pending&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Success&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;transactionId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Failure&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;reason&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ErrorCode&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;          &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Pending&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;referenceId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;                     &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

&lt;span class="c1"&gt;// Exhaustive switch — compiler catches missing cases&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="n"&gt;result&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;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Success&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Paid: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transactionId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Failure&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Failed: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Pending&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Pending: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;referenceId&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;This eliminates entire categories of &lt;code&gt;instanceof&lt;/code&gt; chains and &lt;code&gt;ClassCastException&lt;/code&gt; risks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Text Blocks
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stop escaping quotes in SQL, JSON, GraphQL queries&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""
    SELECT o.id, o.total, c.email
    FROM orders o
    JOIN customers c ON o.customer_id = c.id
    WHERE o.status = 'PAID'
      AND o.created_at &amp;gt; :since
    ORDER BY o.created_at DESC
    """&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What to skip: Java 8 streams chaining for everything
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Stream.of(...).filter(...).map(...).collect(...)&lt;/code&gt; is fine. Chaining 12 operators for a 3-line for-loop is not. Java 16+ &lt;code&gt;toList()&lt;/code&gt;, records, and the enhanced for-loop often produce cleaner code than complex stream pipelines. Don't mistake verbosity for sophistication.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Concurrency — The Biggest Shift in Java History
&lt;/h2&gt;

&lt;p&gt;Virtual threads (GA in Java 21) changed the fundamental model. If you're still writing async/reactive code for I/O-bound services, you're fighting the platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Virtual Threads
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Enable in Spring Boot — one line&lt;/span&gt;
&lt;span class="n"&gt;spring&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threads&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;virtual&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="c1"&gt;// Now every request handler runs on a virtual thread&lt;/span&gt;
&lt;span class="c1"&gt;// Write blocking code. The JVM parks the thread during I/O.&lt;/span&gt;
&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders/{id}"&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;Order&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// blocks, JVM parks virtual thread&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;OrderNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;LineItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;itemRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByOrderId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// same&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;enrich&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;                     &lt;span class="c1"&gt;// same&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// No Mono, no CompletableFuture, no callback hell.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JVM creates virtual threads cheaply (nanoseconds, ~1KB stack). You can have millions. I/O waits don't block the carrier thread. The performance profile of reactive code with the readability of blocking code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured Concurrency (GA in Java 24)
&lt;/h3&gt;

&lt;p&gt;For when you genuinely need to run things in parallel:&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="c1"&gt;// Fan-out: fetch user + orders concurrently, both must succeed&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&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;StructuredTaskScope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ShutdownOnFailure&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Subtask&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="n"&gt;user&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fork&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;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;find&lt;/span&gt;&lt;span class="o"&gt;(&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;Subtask&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fork&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;throwIfFailed&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// waits for both, cancels if either fails&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Dashboard&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="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&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;This replaces the fragile &lt;code&gt;CompletableFuture.allOf(...)&lt;/code&gt; pattern. Lifetimes are scoped — subtasks cannot outlive the block. Cancellation propagates correctly. Stack traces are readable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scoped Values (Java 24)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Replace ThreadLocal — works correctly across virtual threads&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ScopedValue&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RequestContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;REQUEST_CTX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ScopedValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newInstance&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// At request entry point:&lt;/span&gt;
&lt;span class="nc"&gt;ScopedValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;REQUEST_CTX&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;RequestContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;traceId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;processRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Anywhere in the call tree:&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;traceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REQUEST_CTX&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;traceId&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;ThreadLocal&lt;/code&gt; still works but doesn't compose well with virtual threads (pinning risk). Use &lt;code&gt;ScopedValue&lt;/code&gt; for request context in new code.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to skip: Project Reactor / WebFlux for request-response services
&lt;/h3&gt;

&lt;p&gt;WebFlux is the right tool for SSE, WebSocket, and genuine streaming workloads. For CRUD microservices and REST APIs, virtual threads deliver equal throughput with a fraction of the complexity. The ecosystem (blocking JDBC, JPA, most third-party SDKs) is also blocking — fighting it reactively adds overhead without benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Spring Boot 3.x — The Framework Layer
&lt;/h2&gt;

&lt;p&gt;Spring Boot 3 requires Java 17+ and brought a full Jakarta EE migration. By 2026, if you're still on Spring Boot 2.x, this is the year to move.&lt;/p&gt;

&lt;h3&gt;
  
  
  What matters most
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;AOT and GraalVM Native.&lt;/strong&gt; Spring Boot 3 ships native compilation support. For short-lived workloads (FaaS, CLIs, Lambda) native images start in milliseconds and use a fraction of the RAM.&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;plugin&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.graalvm.buildtools&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;native-maven-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mvn &lt;span class="nt"&gt;-Pnative&lt;/span&gt; native:compile
./target/myapp   &lt;span class="c"&gt;# starts in ~50ms, uses ~50MB RAM&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Spring Security 6.&lt;/strong&gt; The &lt;code&gt;WebSecurityConfigurerAdapter&lt;/code&gt; is gone. The new lambda DSL is cleaner:&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;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;SecurityFilterChain&lt;/span&gt; &lt;span class="nf"&gt;security&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpSecurity&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;authorizeHttpRequests&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/actuator/health"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;permitAll&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/admin/**"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;hasRole&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;anyRequest&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;authenticated&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;oauth2ResourceServer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oauth2&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;oauth2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Customizer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDefaults&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&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;strong&gt;Spring Data 3.x.&lt;/strong&gt; &lt;code&gt;PagingAndSortingRepository&lt;/code&gt; is now separate from &lt;code&gt;CrudRepository&lt;/code&gt;. &lt;code&gt;JpaRepository&lt;/code&gt; extends both. Projections with interfaces or records are the cleanest way to fetch partial data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actuator + Micrometer + OpenTelemetry.&lt;/strong&gt; This stack is the observability standard. Every Spring Boot 3 service should export traces and metrics to your observability backend via OTLP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tracing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sampling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;probability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.0&lt;/span&gt;
  &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tracing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://otel-collector:4318/v1/traces&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. AI Integration — The New Differentiator
&lt;/h2&gt;

&lt;p&gt;Java developers who can wire LLMs into production systems are in the top 5% of the market right now. This is the highest-leverage skill to add in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spring AI (the production choice)
&lt;/h3&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.ai&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-ai-openai-spring-boot-starter&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&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;DocumentAnalysisService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;VectorStore&lt;/span&gt; &lt;span class="n"&gt;vectorStore&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// RAG: embed query, retrieve context, generate answer&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;answer&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;question&lt;/span&gt;&lt;span class="o"&gt;)&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;Document&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vectorStore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;similaritySearch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;SearchRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;withTopK&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&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;contextText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&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="nl"&gt;Document:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getContent&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;"\n---\n"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Answer using only the context provided. If the answer is not there, say so."&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Context:\n{ctx}\n\nQuestion: {q}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ctx"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contextText&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"q"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;content&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;h3&gt;
  
  
  Key Spring AI concepts to learn in order
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;ChatClient&lt;/code&gt; — the main abstraction for LLM calls&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Structured outputs&lt;/code&gt; — returning Java POJOs instead of raw strings&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VectorStore&lt;/code&gt; + &lt;code&gt;EmbeddingModel&lt;/code&gt; — RAG foundation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Tool/Function calling&lt;/code&gt; — letting LLMs call your Java methods&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ChatMemory&lt;/code&gt; + &lt;code&gt;Advisors&lt;/code&gt; — conversation state, prompt composition&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Observability&lt;/code&gt; — tracing LLM calls with Micrometer&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What to skip: Building your own LLM abstraction layer
&lt;/h3&gt;

&lt;p&gt;LangChain4j and Spring AI cover 95% of production AI use cases. Rolling a custom HTTP client to the OpenAI API is fine for learning but builds a maintenance liability you'll regret in six months.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Cloud-Native — Table Stakes
&lt;/h2&gt;

&lt;p&gt;If you're building services in 2026, cloud-native patterns are not optional.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker and Kubernetes (know the basics)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; eclipse-temurin:21-jre-alpine&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; target/app.jar /app.jar&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["java", "-jar", "/app.jar"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Kubernetes readiness + liveness via Spring Actuator&lt;/span&gt;
&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health/liveness&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health/readiness&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't need to be a Kubernetes expert. You do need to know: how to containerize an app, what readiness/liveness probes do, how ConfigMaps and Secrets map to env vars, and how HPA works.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenTelemetry — the observability standard
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// With Spring Boot 3 Actuator + OTLP exporter, traces are automatic.&lt;/span&gt;
&lt;span class="c1"&gt;// Add baggage for business context:&lt;/span&gt;
&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders/{id}"&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;Order&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;current&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order.id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... rest of handler&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Logs, traces, and metrics in one collector. Stop learning proprietary agents for each APM vendor.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to skip: Manual Dockerfiles with fat JARs on JDK images
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;eclipse-temurin:21-jre-alpine&lt;/code&gt; (not JDK) cuts image size by ~300MB. Spring Boot's layered JAR support (&lt;code&gt;spring-boot-maven-plugin&lt;/code&gt; layer configuration) and &lt;code&gt;Paketo Buildpacks&lt;/code&gt; produce better images than handwritten Dockerfiles for most teams. Use buildpacks first, hand-roll only when you have a specific reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Learning Path by Experience Level
&lt;/h2&gt;

&lt;h3&gt;
  
  
  0–2 Years (Junior)
&lt;/h3&gt;

&lt;p&gt;Focus here, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Java 21 core — records, sealed classes, pattern matching, text blocks&lt;/li&gt;
&lt;li&gt;Spring Boot 3 basics — REST, JPA, validation, security fundamentals&lt;/li&gt;
&lt;li&gt;Testing — JUnit 5, Mockito, &lt;code&gt;@SpringBootTest&lt;/code&gt; vs slice tests&lt;/li&gt;
&lt;li&gt;Git + Docker basics&lt;/li&gt;
&lt;li&gt;SQL — JOINs, indexes, EXPLAIN — know your database&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Skip for now:&lt;/strong&gt; Reactive programming, microservices architecture, AI integration, Kubernetes internals. Complexity before fundamentals creates engineers who can't debug.&lt;/p&gt;

&lt;h3&gt;
  
  
  2–5 Years (Mid-level)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Virtual threads and structured concurrency — replace your thread pool mental model&lt;/li&gt;
&lt;li&gt;Spring Boot 3 production patterns — Actuator, Micrometer, OpenTelemetry&lt;/li&gt;
&lt;li&gt;Microservices fundamentals — API design, inter-service communication (REST vs gRPC), circuit breakers&lt;/li&gt;
&lt;li&gt;Cloud basics — one of AWS/GCP/Azure to associate level&lt;/li&gt;
&lt;li&gt;Spring AI basics — RAG pipeline, tool calling, structured outputs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The leverage move at this level:&lt;/strong&gt; observability. Engineers who can find production issues fast are disproportionately valuable.&lt;/p&gt;

&lt;h3&gt;
  
  
  5+ Years (Senior / Lead)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;AI system design — RAG architecture, agent orchestration, LLMOps&lt;/li&gt;
&lt;li&gt;Performance engineering — virtual thread profiling, JVM GC tuning, DB query optimization&lt;/li&gt;
&lt;li&gt;Platform engineering — Kubernetes operators, Helm, GitOps&lt;/li&gt;
&lt;li&gt;Architecture patterns — event-driven design, CQRS, saga&lt;/li&gt;
&lt;li&gt;Engineering leadership — technical direction, system design interviews, code review culture&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The leverage move at this level:&lt;/strong&gt; mentorship. The engineers you bring up multiply your output more than any technical skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "What to Stop Doing" List
&lt;/h2&gt;

&lt;p&gt;This is the section people argue about. That's the point.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stop doing&lt;/th&gt;
&lt;th&gt;Do this instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@Autowired&lt;/code&gt; field injection&lt;/td&gt;
&lt;td&gt;Constructor injection — testable, explicit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;extends HttpServlet&lt;/code&gt; / raw servlets&lt;/td&gt;
&lt;td&gt;Spring MVC or Spring WebFlux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XML Spring config&lt;/td&gt;
&lt;td&gt;Java config + &lt;code&gt;@Bean&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;java.util.Date&lt;/code&gt; / &lt;code&gt;Calendar&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;java.time.*&lt;/code&gt; — &lt;code&gt;LocalDate&lt;/code&gt;, &lt;code&gt;ZonedDateTime&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual JSON parsing with &lt;code&gt;JSONObject&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Jackson / Gson with typed POJOs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mutable POJOs with 30 setters&lt;/td&gt;
&lt;td&gt;Records or immutable value objects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;synchronized&lt;/code&gt; blocks for everything&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ReentrantLock&lt;/code&gt;, &lt;code&gt;Semaphore&lt;/code&gt;, virtual threads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ThreadLocal&lt;/code&gt; for request context&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ScopedValue&lt;/code&gt; (Java 24+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catching &lt;code&gt;Exception&lt;/code&gt; everywhere&lt;/td&gt;
&lt;td&gt;Specific exceptions; let runtime exceptions propagate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Log.info("Got order: " + orderId)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;log.info("Got order: {}", orderId)&lt;/code&gt; — no string concat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Building your own retry logic&lt;/td&gt;
&lt;td&gt;Resilience4j or Spring Retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rolling your own JWT validation&lt;/td&gt;
&lt;td&gt;Spring Security's OAuth2 resource server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Skills That Actually Get You Hired in 2026
&lt;/h2&gt;

&lt;p&gt;In order of what hiring managers actually test for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;System design&lt;/strong&gt; — can you design a scalable, observable microservice under ambiguous requirements?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debugging&lt;/strong&gt; — can you find a production issue using logs, traces, and metrics without stepping through code?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code quality&lt;/strong&gt; — do your PRs need one review cycle or five?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI integration&lt;/strong&gt; — can you wire an LLM into a service that works reliably in production (not just a demo)?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Communication&lt;/strong&gt; — can you explain a technical decision to a non-technical stakeholder?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Leetcode matters for FAANG and trading companies. For the vast majority of backend Java roles, system design and real-world debugging matter more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources Worth Your Time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Language:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://openjdk.org/jeps/0" rel="noopener noreferrer"&gt;JEP Index&lt;/a&gt; — primary source for new Java features, better than any blog summary&lt;/li&gt;
&lt;li&gt;Inside Java podcast — authoritative, from the Java team&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Spring:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://spring.io/blog" rel="noopener noreferrer"&gt;Spring Blog&lt;/a&gt; — official, high signal&lt;/li&gt;
&lt;li&gt;Spring One talks on YouTube — architecture deep-dives by the framework authors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;AI / LLM:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spring AI reference docs — the most up-to-date source for Spring AI patterns&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://buildingai.in/blog" rel="noopener noreferrer"&gt;AI Engineering&lt;/a&gt; — practical guides on RAG, agents, LLMOps in Java&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;System Design:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; (Kleppmann) — still the best book on distributed systems&lt;/li&gt;
&lt;li&gt;ByteByteGo newsletter — good visual breakdowns of production architectures&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One More Thing
&lt;/h2&gt;

&lt;p&gt;Java in 2026 is not your Java of 2015. It's fast, expressive, and the ecosystem for AI integration is mature. The engineers who treat it as the "boring enterprise language" are leaving real opportunity on the table.&lt;/p&gt;

&lt;p&gt;The engineers who will be most valuable in the next three years are the ones who can build reliable AI-powered systems in Java — because that's where the enterprise deals are, and that's where the Java ecosystem is heading.&lt;/p&gt;

&lt;p&gt;Pick one section of this roadmap that you're weakest in. Work on it for 90 days. Then come back to the next one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disagreements welcome — that's how roadmaps improve. Find me on &lt;a href="https://linkedin.com/in/avaneeshyadav" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or browse the full &lt;a href="https://buildingai.in/blog" rel="noopener noreferrer"&gt;buildingai.in blog&lt;/a&gt; for deep-dives on each section of this roadmap.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>career</category>
      <category>java</category>
      <category>learning</category>
    </item>
    <item>
      <title>Temporal vs Spring Batch: Fixing Enterprise Java's Silent Job Failure Problem</title>
      <dc:creator>Avaneesh Yadav</dc:creator>
      <pubDate>Fri, 28 Aug 2026 06:26:08 +0000</pubDate>
      <link>https://dev.to/avaneeshyadav/temporal-vs-spring-batch-fixing-enterprise-javas-silent-job-failure-problem-4mag</link>
      <guid>https://dev.to/avaneeshyadav/temporal-vs-spring-batch-fixing-enterprise-javas-silent-job-failure-problem-4mag</guid>
      <description>&lt;p&gt;Enterprise Java teams commonly reach a point where scheduled jobs become a liability rather than an asset. Jobs fail silently — catching exceptions, logging nothing meaningful, and returning success to the scheduler. Others run simultaneously because the previous execution didn't finish before the next trigger fired and nobody configured &lt;code&gt;@ScheduledLock&lt;/code&gt;. Data corruption from concurrent runs can take weeks to surface.&lt;/p&gt;

&lt;p&gt;None of this shows up in dashboards until a data audit flags inconsistencies in a critical report.&lt;/p&gt;

&lt;p&gt;This guide explains why &lt;code&gt;@Scheduled&lt;/code&gt; breaks down at scale, how Temporal's durable execution model eliminates these problems, and how to migrate a production Spring Batch pipeline with complete code examples.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Temporal Actually Is (In One Paragraph)
&lt;/h2&gt;

&lt;p&gt;Temporal is a &lt;strong&gt;durable execution&lt;/strong&gt; platform. You write plain Java code — functions, loops, try/catch — and Temporal makes it fault-tolerant by recording every state transition to an event log. If the process crashes mid-execution, it replays the log and resumes from exactly where it left off. There's no external state machine to define, no checkpoint tables to maintain, no retry logic to write. The code IS the workflow.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph TD
  subgraph BEFORE ["Before — Spring Scheduler + Batch"]
    C1[Cron Trigger] --&amp;gt; J1[@Scheduled Method]
    J1 --&amp;gt; J2[Spring Batch Job]
    J2 --&amp;gt; J3[ItemReader]
    J3 --&amp;gt; J4[ItemProcessor]
    J4 --&amp;gt; J5[ItemWriter]
    J5 --&amp;gt; J6[DB commit]
    E1[Failure?] -.-&amp;gt;|swallowed| X1[😶 Silent]
    E2[Overlap?] -.-&amp;gt;|no guard| X2[💥 Corruption]
  end

  subgraph AFTER ["After — Temporal"]
    T1[Temporal Scheduler] --&amp;gt; W1[Workflow Method]
    W1 --&amp;gt; A1[Activity: Read chunk]
    W1 --&amp;gt; A2[Activity: Process chunk]
    W1 --&amp;gt; A3[Activity: Write chunk]
    F1[Failure?] -.-&amp;gt;|auto-retry| W1
    F2[Crash?] -.-&amp;gt;|replay from log| W1
    F3[Overlap?] -.-&amp;gt;|workflow ID lock| X3[✅ Prevented]
  end&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  The Problem With @Scheduled at Scale
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@Scheduled&lt;/code&gt; works fine for one or two simple jobs. By the time a team has dozens, accumulated debt becomes significant:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No visibility.&lt;/strong&gt; Which jobs ran? Which failed? How long did they take? Spring's scheduler offers nothing here by default. Teams bolt on Actuator, add custom logging, hook up Micrometer. By the time there's real observability it's a custom framework to maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No fault tolerance.&lt;/strong&gt; An exception kills the job instance. Whether it retries, and how, is the team's problem. Teams solve this inconsistently — some catch-and-retry inline, some use Spring Retry, some let it silently fail and rely on the next scheduled trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No distributed locking.&lt;/strong&gt; &lt;code&gt;@ScheduledLock&lt;/code&gt; with ShedLock or Quartz clustering works, but it's an additional library, additional config, and an additional failure mode (what happens when the lock row gets corrupted?).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing is painful.&lt;/strong&gt; Unit tests can cover job logic, but testing scheduling behavior — does it actually retry? does it respect the lock? — requires either waiting for real time to pass or mocking the scheduler in ways that diverge from production behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spring Batch adds its own complexity.&lt;/strong&gt; Job metadata tables (&lt;code&gt;BATCH_JOB_INSTANCE&lt;/code&gt;, &lt;code&gt;BATCH_JOB_EXECUTION&lt;/code&gt;, etc.) need to be managed, pruned, and kept consistent. The restart/skip/retry model is powerful but verbose to configure. And Spring Batch has no concept of a workflow spanning multiple jobs — that becomes custom orchestration code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before: A Typical Spring Batch Job
&lt;/h2&gt;

&lt;p&gt;Here's a representative pattern — daily invoice reconciliation, reading from a &lt;code&gt;pending_invoices&lt;/code&gt; table, processing, and writing to &lt;code&gt;processed_invoices&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="c1"&gt;// Cron trigger — buried in application.yml&lt;/span&gt;
&lt;span class="c1"&gt;// cron: "0 0 2 * * *"&lt;/span&gt;

&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&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;InvoiceReconciliationScheduler&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;JobLauncher&lt;/span&gt; &lt;span class="n"&gt;jobLauncher&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Job&lt;/span&gt; &lt;span class="n"&gt;invoiceReconciliationJob&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cron&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${jobs.invoice-reconciliation.cron}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@SchedulerLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"invoiceReconciliation"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lockAtMostFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"PT2H"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;JobParameters&lt;/span&gt; &lt;span class="n"&gt;params&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;JobParametersBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addLong&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"run.id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toJobParameters&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;jobLauncher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoiceReconciliationJob&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&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;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;"Invoice reconciliation failed"&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="c1"&gt;// No alert. No retry. No escalation.&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="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&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;InvoiceReconciliationJobConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Job&lt;/span&gt; &lt;span class="nf"&gt;invoiceReconciliationJob&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Step&lt;/span&gt; &lt;span class="n"&gt;reconcileStep&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="n"&gt;jobBuilderFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoiceReconciliationJob"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reconcileStep&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Step&lt;/span&gt; &lt;span class="nf"&gt;reconcileStep&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="n"&gt;stepBuilderFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"reconcileStep"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PendingInvoice&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProcessedInvoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;chunk&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;reader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoiceReader&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;processor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoiceProcessor&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoiceWriter&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;faultTolerant&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retryLimit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TransientDataAccessException&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... reader/processor/writer beans&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is roughly 150 lines across three files for one job. It has per-item retry (good), but no workflow-level retry, no timeout enforcement, no alerting on failure, and no way to see execution history without querying the batch tables directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  After: The Same Job in Temporal
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Workflow interface — the contract&lt;/span&gt;
&lt;span class="nd"&gt;@WorkflowInterface&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;InvoiceReconciliationWorkflow&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@WorkflowMethod&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;reconcile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Activity interface — the actual I/O work&lt;/span&gt;
&lt;span class="nd"&gt;@ActivityInterface&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;InvoiceReconciliationActivities&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;PendingInvoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;readChunk&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;date&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;offset&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;limit&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;ProcessedInvoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;processChunk&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;PendingInvoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;writeChunk&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;ProcessedInvoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;countPending&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Workflow implementation — plain Java, no framework magic&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;InvoiceReconciliationWorkflowImpl&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;InvoiceReconciliationWorkflow&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;InvoiceReconciliationActivities&lt;/span&gt; &lt;span class="n"&gt;activities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newActivityStub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvoiceReconciliationActivities&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="nc"&gt;ActivityOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStartToCloseTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRetryOptions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RetryOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMaximumAttempts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDoNotRetry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IllegalArgumentException&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="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;reconcile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="o"&gt;)&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;activities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;countPending&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&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;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;chunkSize&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="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;)&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;PendingInvoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;activities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readChunk&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunkSize&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;ProcessedInvoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;processed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;activities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;processChunk&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;activities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeChunk&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;chunkSize&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="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Schedule registration — once, at startup&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;scheduleClient&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;createSchedule&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"invoice-reconciliation-daily"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;ScheduleSpec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCronExpressions&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;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0 2 * * *"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
    &lt;span class="nc"&gt;ScheduleAction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startWorkflow&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;InvoiceReconciliationWorkflow&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="s"&gt;"reconcile"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;WorkflowOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setWorkflowId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice-recon-"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&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="na"&gt;setTaskQueue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice-queue"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setWorkflowExecutionTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&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 workflow body is 12 lines. It reads like the business requirement. No chunk configuration DSL, no step beans, no job parameter boilerplate.&lt;/p&gt;

&lt;p&gt;Because the workflow ID includes the date (&lt;code&gt;invoice-recon-2026-08-28&lt;/code&gt;), attempting to start a second execution for the same day throws &lt;code&gt;WorkflowExecutionAlreadyStarted&lt;/code&gt; — the overlap corruption problem is gone by construction, with no lock to configure or expire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Get for Free
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Retries with backoff.&lt;/strong&gt; Every activity gets the retry policy defined at registration. Failed activities retry automatically, with exponential backoff, without any code in the activity itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crash recovery.&lt;/strong&gt; If the worker process dies mid-workflow, the next worker that picks up the task queue replays the event history and continues from the last completed activity. No data is lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visibility out of the box.&lt;/strong&gt; Temporal's web UI shows every workflow execution: start time, current state, activity history, retry attempts, failures, input and output. What would take custom Micrometer instrumentation now comes for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workflow ID deduplication.&lt;/strong&gt; Using a business-meaningful workflow ID (like &lt;code&gt;invoice-recon-2026-08-28&lt;/code&gt;) means the same logical job can never run twice. This is structurally better than distributed locking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testable without real time.&lt;/strong&gt; Temporal's &lt;code&gt;TestWorkflowEnvironment&lt;/code&gt; lets you test the entire workflow including retries, timeouts, and activity failures — in unit tests, without waiting for real timers.&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;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldRetryFailedActivity&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;TestWorkflowEnvironment&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestWorkflowEnvironment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newInstance&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Worker&lt;/span&gt; &lt;span class="n"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newWorker&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice-queue"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerActivitiesImplementations&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;InvoiceActivitiesFailing&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;failOnAttempt:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerWorkflowImplementationTypes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvoiceReconciliationWorkflowImpl&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="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;InvoiceReconciliationWorkflow&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getWorkflowClient&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newWorkflowStub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvoiceReconciliationWorkflow&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="nc"&gt;WorkflowOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setTaskQueue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice-queue"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="c1"&gt;// Runs in milliseconds — Temporal's test env skips real timers&lt;/span&gt;
    &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reconcile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&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="mi"&gt;28&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failingActivity&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;readChunk&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;anyInt&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;anyInt&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// retried once&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common Migration Pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Activity timeouts.&lt;/strong&gt; &lt;code&gt;StartToCloseTimeout&lt;/code&gt; must be set conservatively — Temporal cancels and retries the activity if it exceeds the timeout. Set it to the P99 execution time, not the average. Activities that occasionally run for 45 minutes on large-backlog days need that reflected in their timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-idempotent writers.&lt;/strong&gt; Temporal retries activities automatically. If a writer inserts rows without an upsert, retries produce duplicates. Every write activity must be idempotent — &lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt; on the write side before migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;History size limits.&lt;/strong&gt; Temporal's event history has a default limit of 50,000 events. A workflow processing 500k records in 500-row chunks generates 3,000 activity calls — fine. Processing row-by-row would hit the limit. Design activities to operate on chunks, not individual records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local dev environment.&lt;/strong&gt; Running Temporal locally requires Docker (&lt;code&gt;temporalio/temporal&lt;/code&gt;). Teams without Docker in their standard dev setup will have friction on day one. Add it to the team devcontainer or docker-compose before rollout.&lt;/p&gt;

&lt;p&gt;[!NOTE]&lt;br&gt;
Temporal's Java SDK (version 2.x) works with Spring Boot 3.2+ virtual threads automatically. Pair it with &lt;code&gt;spring.threads.virtual.enabled=true&lt;/code&gt; and worker threads scale to thousands of concurrent activity executions on minimal OS threads.&lt;/p&gt;
&lt;h2&gt;
  
  
  When to Keep Spring Batch
&lt;/h2&gt;

&lt;p&gt;Not every job should move to Temporal. Spring Batch remains the right tool in two scenarios:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regulated data processing with mandatory audit trails.&lt;/strong&gt; Spring Batch's job metadata schema (&lt;code&gt;BATCH_JOB_INSTANCE&lt;/code&gt;, &lt;code&gt;BATCH_JOB_EXECUTION&lt;/code&gt;) is a ready-made audit log that compliance and operations teams can query directly in the database. Temporal's event history lives in Temporal's own store — visible in the UI, but not in your RDBMS. For financial reporting jobs with regulatory requirements, Spring Batch's schema is an asset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pure ETL with no orchestration complexity.&lt;/strong&gt; A job that reads a file, transforms rows, writes to a table, and exits is exactly what Spring Batch was designed for. No retry logic, no dependencies between steps, no timeout sensitivity. Rewriting it in Temporal adds infrastructure without adding value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision rule:&lt;/strong&gt; if you would describe the job as "a workflow" — with branches, dependencies, or conditions — use Temporal. If you would describe it as "a batch" — fixed input, transform, fixed output — Spring Batch is the right fit.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&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;io.temporal&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;temporal-spring-boot-starter&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.4.0&lt;span class="nt"&gt;&amp;lt;/version&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# application.yml&lt;/span&gt;
&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;temporal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost:7233&lt;/span&gt;
    &lt;span class="na"&gt;workers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task-queue&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;invoice-queue&lt;/span&gt;
        &lt;span class="na"&gt;workflow-classes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;com.example.workflows.InvoiceReconciliationWorkflowImpl&lt;/span&gt;
        &lt;span class="na"&gt;activity-beans&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;invoiceReconciliationActivities&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Temporal's Spring Boot starter auto-wires workers, injects &lt;code&gt;WorkflowClient&lt;/code&gt; and &lt;code&gt;ScheduleClient&lt;/code&gt; as beans, and handles graceful shutdown.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Local dev — start Temporal server&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 7233:7233 &lt;span class="nt"&gt;-p&lt;/span&gt; 8233:8233 temporalio/temporal:latest
&lt;span class="c"&gt;# UI available at http://localhost:8233&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Migration Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Inventory all &lt;code&gt;@Scheduled&lt;/code&gt; and Spring Batch jobs — map dependencies between them&lt;/li&gt;
&lt;li&gt;[ ] Classify: "workflow" (Temporal) vs "pure batch" (keep Spring Batch)&lt;/li&gt;
&lt;li&gt;[ ] Make all activity implementations idempotent (upsert, not insert)&lt;/li&gt;
&lt;li&gt;[ ] Set &lt;code&gt;StartToCloseTimeout&lt;/code&gt; at P99 of execution time, not average&lt;/li&gt;
&lt;li&gt;[ ] Use business-meaningful workflow IDs for deduplication&lt;/li&gt;
&lt;li&gt;[ ] Add Temporal Docker to dev environment / docker-compose&lt;/li&gt;
&lt;li&gt;[ ] Run parallel execution for 2 weeks (old scheduler + Temporal) and compare outputs&lt;/li&gt;
&lt;li&gt;[ ] Cut over the scheduler trigger, remove old &lt;code&gt;@Scheduled&lt;/code&gt; beans&lt;/li&gt;
&lt;li&gt;[ ] Configure Temporal Worker autoscaling (CPU-based HPA works well on Kubernetes)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@Scheduled&lt;/code&gt; is fine for one or two simple jobs. Beyond that, teams face silent failures, concurrent execution bugs, and zero visibility — problems that only surface when they've already caused damage.&lt;/p&gt;

&lt;p&gt;Temporal solves all three at the infrastructure level: durable execution for crash recovery, workflow ID deduplication for concurrency control, and a built-in UI for visibility. The workflow code is shorter than Spring Batch equivalent, easier to read, and testable in isolation without mocking time.&lt;/p&gt;

&lt;p&gt;For teams with more than 10 scheduled jobs, or any jobs with dependencies and retries, Temporal is worth evaluating before the next production incident makes the case for you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Working with Temporal in Java or evaluating the migration? Happy to discuss patterns and pitfalls. Find me on &lt;a href="https://linkedin.com/in/avaneeshyadav" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>java</category>
      <category>spring</category>
    </item>
    <item>
      <title>AI Coding Tools in Enterprise: What 90 Days of Data Actually Shows</title>
      <dc:creator>Avaneesh Yadav</dc:creator>
      <pubDate>Fri, 28 Aug 2026 06:26:06 +0000</pubDate>
      <link>https://dev.to/avaneeshyadav/ai-coding-tools-in-enterprise-what-90-days-of-data-actually-shows-15gd</link>
      <guid>https://dev.to/avaneeshyadav/ai-coding-tools-in-enterprise-what-90-days-of-data-actually-shows-15gd</guid>
      <description>&lt;p&gt;Everyone has opinions on AI coding tools. Not enough teams publish the data.&lt;/p&gt;

&lt;p&gt;This post covers a structured 90-day study of AI coding assistants across a 70-engineer enterprise team — frontend, backend, platform, and QA. The metrics tracked: PR velocity, review cycles, defect escape rate, time-to-first-review, and self-reported time savings. Tools used: Claude Code (primary), GitHub Copilot (one squad for comparison), Cursor (three frontend engineers).&lt;/p&gt;

&lt;p&gt;The results were good enough to justify the investment. They were also more nuanced than the vendor benchmarks suggest — and two findings changed how to think about rollout strategy entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Study Setup
&lt;/h2&gt;

&lt;p&gt;A 6-month baseline was established before any tooling changes. PR metrics from GitHub, defect data from Jira, monthly velocity surveys. Then a structured rollout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Week 1–2:&lt;/strong&gt; Tool access granted, zero guidance. Observe organic adoption patterns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 3–4:&lt;/strong&gt; Prompt engineering training — two 90-minute sessions per squad.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Month 2–3:&lt;/strong&gt; Embedded in workflows, metrics collected, weekly retros.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI tool use was not mandated. Engineers could opt out. Two did. Both eventually opted back in by week 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Baseline (6mo avg)&lt;/th&gt;
&lt;th&gt;After 90 days&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PRs merged per engineer/week&lt;/td&gt;
&lt;td&gt;3.2&lt;/td&gt;
&lt;td&gt;4.1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+28%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median PR review cycles&lt;/td&gt;
&lt;td&gt;2.4&lt;/td&gt;
&lt;td&gt;1.9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−21%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first review (hours)&lt;/td&gt;
&lt;td&gt;18.3&lt;/td&gt;
&lt;td&gt;14.7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−20%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P1/P2 defect escape rate&lt;/td&gt;
&lt;td&gt;4.1%&lt;/td&gt;
&lt;td&gt;3.8%&lt;/td&gt;
&lt;td&gt;−7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test coverage (avg across repos)&lt;/td&gt;
&lt;td&gt;61%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+13pp&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The velocity number (+28% PRs) looks strong. The defect number (−7%) is smaller than expected given the coverage jump. Understanding why is the most important part of this analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Improved
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test writing — the biggest win by far.&lt;/strong&gt; Engineers consistently reported that writing tests shifted from the most-avoided task to one they delegated to AI first. Test coverage jumped 13 percentage points in 90 days. For teams where test coverage has been a long-standing problem, this alone justifies the tool cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boilerplate elimination.&lt;/strong&gt; REST controllers, DTO classes, mapper code, migration scripts — all measurably faster. Engineers with 2–5 years of experience saw the largest gains here. Senior engineers who had already internalized these patterns saw smaller improvements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation.&lt;/strong&gt; Javadoc, OpenAPI annotations, README sections — written without prompting once AI made the activation energy low. This is often where quality debt accumulates silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Onboarding speed in unfamiliar codebases.&lt;/strong&gt; The "explain this code" use case is underrated. New engineers using AI to orient themselves in large repos reduced the time senior engineers spent on walkthrough sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Did Not Improve (Or Got Worse)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Defect rate on complex logic.&lt;/strong&gt; The 7% improvement in defect escape rate is smaller than expected — and in two recorded incidents it was negative. Engineers over-trusted AI-generated business logic for domain-heavy calculations. The code was syntactically correct and plausible-looking, but semantically wrong in subtle ways. Both bugs passed code review because reviewers assumed AI-generated code had already been validated.&lt;/p&gt;

&lt;p&gt;[!WARNING]&lt;br&gt;
This is the most important finding: AI coding tools shift the defect profile. Obvious errors decrease. Plausible-but-wrong logic errors stay flat or increase if review culture doesn't adapt. Code review must become &lt;em&gt;more&lt;/em&gt; skeptical, not less, when AI is involved — especially in the logic layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code review quality initially degraded.&lt;/strong&gt; In month 1, PR review times dropped because reviewers were approving faster — not because the code was better. This surfaced in retrospectives and required explicit re-training: AI-generated code needs the same scrutiny as human-written code, applied to different layers (logic correctness over syntax).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt skill gap created a new inequality.&lt;/strong&gt; Engineers who could write precise, context-rich prompts got dramatically more value than those who couldn't. A senior engineer with strong prompting skills might see 40% velocity improvement. A junior engineer with weak prompting might see 5%. AI tools were expected to have a leveling effect. Instead, the skill gap widened before it narrowed — and that widening was largest in the first 4 weeks before training ran.&lt;/p&gt;
&lt;h2&gt;
  
  
  What Prompt Training Actually Changed
&lt;/h2&gt;

&lt;p&gt;The two 90-minute sessions in weeks 3–4 had measurable impact on output quality. Here's what moved the needle:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context loading is the highest-leverage habit.&lt;/strong&gt; Engineers who pasted only a function signature and asked for help got mediocre results. Engineers who loaded the surrounding module, relevant interfaces, and a description of system invariants got dramatically better output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Negative constraints outperform positive ones.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Weak — what most engineers start with:
"Write a service that processes customer invoices"

// Strong — what high performers use:
"Write a Spring service that processes customer invoices.
Do not introduce new dependencies. Do not use @Autowired field injection.
Follow the pattern in OrderService.java. The invoice total must never be
mutated after creation — use a new ProcessedInvoice object.
Explain your approach before writing any code."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"Explain first" for complex logic.&lt;/strong&gt; Asking the AI to reason through the approach before writing code caught more errors than reviewing code after the fact. A 30-second prompt change saved hours of debugging in several cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iterative over one-shot.&lt;/strong&gt; Engineers who treated AI as a pair programmer (one step at a time, verify, continue) consistently outperformed those who generated large code blocks and integrated them whole.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost vs Return
&lt;/h2&gt;

&lt;p&gt;Enterprise account pricing varies. A representative blended figure across tool tiers: approximately &lt;strong&gt;$38/engineer/month&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Self-reported time saved per engineer per week, validated against PR metrics: &lt;strong&gt;3.2 hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At a blended fully-loaded hourly cost of $95/hour:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool cost:&lt;/strong&gt; $38/month ≈ $9.50/week&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value of time saved:&lt;/strong&gt; 3.2h × $95 = $304/week&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ROI:&lt;/strong&gt; ~32× on direct time savings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This excludes quality, onboarding speed, documentation improvements, and developer satisfaction effects. Even with a 90% discount on self-reported savings, the economics are strongly positive.&lt;/p&gt;

&lt;p&gt;[!NOTE]&lt;br&gt;
The highest self-reported savings came from mid-level engineers (3–6 years) with clear, well-scoped tasks. Senior engineers doing architecture or production debugging reported smaller direct gains but significant value in AI-assisted research and API exploration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Process Changes Worth Making
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Disclose AI-generated code in PRs.&lt;/strong&gt; Not because AI code is worse on average — it isn't. But reviewers need to know to apply deeper scrutiny to the business logic layer rather than the syntax layer. A simple checkbox in the PR template is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt engineering on day one of onboarding.&lt;/strong&gt; Engineers who receive a 2-hour prompt training session on their first week reach productive AI use in 1–2 weeks. Engineers who don't typically take 6–8 weeks to use AI effectively. Front-loading this is the single highest-leverage structural change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run prompt retrospectives.&lt;/strong&gt; Every two weeks, one engineer shares a prompt that worked unexpectedly well or failed unexpectedly. 15 minutes. Document the good ones in a shared internal knowledge base. This consistently becomes one of the highest-engagement retro formats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add AI hallucinated dependencies to security review.&lt;/strong&gt; Security teams should specifically look for AI-hallucinated library versions and dependency confusion in AI-assisted PRs. This class of issue appears reliably enough to warrant a dedicated check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways for Engineering Leaders
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The ROI is real, but it's not evenly distributed.&lt;/strong&gt; Velocity improvements are largest for engineers with clear, well-scoped tasks and strong prompting habits. Expecting uniform gains across all experience levels and task types leads to disappointed stakeholders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The defect profile changes, not just the rate.&lt;/strong&gt; Prepare reviewers for this before rollout, not after the first incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured training pays back fast.&lt;/strong&gt; The teams that ran prompt engineering sessions in week 1 outperformed those who installed the tools and called it done. The marginal cost of two 90-minute sessions is negligible against the compounding return.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Track coverage and defect rate, not just velocity.&lt;/strong&gt; Velocity is the easiest metric to move with AI tools. Quality metrics tell you whether the gains are real or just moving fast in the wrong direction.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Measuring AI coding tool ROI at your organization? The defect-rate effects and review culture changes are the least-discussed parts of this space — always interested to compare notes. Find me on &lt;a href="https://linkedin.com/in/avaneeshyadav" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>We Replaced Spring WebFlux with Virtual Threads. Here's the Honest Result.</title>
      <dc:creator>Avaneesh Yadav</dc:creator>
      <pubDate>Tue, 25 Aug 2026 18:07:06 +0000</pubDate>
      <link>https://dev.to/avaneeshyadav/we-replaced-spring-webflux-with-virtual-threads-heres-the-honest-result-49ab</link>
      <guid>https://dev.to/avaneeshyadav/we-replaced-spring-webflux-with-virtual-threads-heres-the-honest-result-49ab</guid>
      <description>&lt;p&gt;Three years ago I would have told you Spring WebFlux was the future of high-throughput Java services.&lt;/p&gt;

&lt;p&gt;I was wrong — for most services.&lt;/p&gt;

&lt;p&gt;This isn't a hot take. It's a post-mortem. In late 2025 we migrated four production microservices off WebFlux onto Spring Boot 3.3 with virtual threads. The results were good enough that I owe it to the Java community to write them down honestly — including the two services where we kept reactive, and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why We Chose WebFlux in the First Place
&lt;/h2&gt;

&lt;p&gt;Let's be fair to past-us. The decision made sense in 2022:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We were running on 2-core Fargate tasks with 512MB RAM&lt;/li&gt;
&lt;li&gt;The services were mostly I/O-bound (database + downstream HTTP calls)&lt;/li&gt;
&lt;li&gt;We'd seen the benchmarks — WebFlux could handle 10× the concurrent requests on the same hardware compared to a blocking Spring MVC stack&lt;/li&gt;
&lt;li&gt;The team had a few engineers who genuinely understood Project Reactor&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We got what we paid for. Thread counts stayed low. Latency was good. The services scaled cheaply.&lt;/p&gt;

&lt;p&gt;So why did we migrate?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost That Doesn't Show Up in Benchmarks
&lt;/h2&gt;

&lt;p&gt;Reactive programming has a tax. You pay it in developer time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack traces became archaeological digs.&lt;/strong&gt; A &lt;code&gt;NullPointerException&lt;/code&gt; 12 Mono operators deep produces a stack trace 40 frames long, most of which is Reactor internals. Debugging a production issue meant peeling back the operator chain layer by layer, mentally reconstructing the execution path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context propagation was a constant source of bugs.&lt;/strong&gt; MDC logging, security context, distributed tracing — all of these depend on &lt;code&gt;ThreadLocal&lt;/code&gt; state that simply doesn't work in a reactive pipeline. We had to manually propagate &lt;code&gt;ReactiveSecurityContextHolder&lt;/code&gt;, wrap tracing spans into the Reactor Context, and write a custom &lt;code&gt;MdcContextLifter&lt;/code&gt;. This was boilerplate we wrote once and then forgot about until it broke during an upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Onboarding new engineers took weeks.&lt;/strong&gt; Not days. Weeks. We'd hand someone a &lt;code&gt;Mono&amp;lt;Flux&amp;lt;ResponseEntity&amp;lt;List&amp;lt;OrderDTO&amp;gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; return type and watch their eyes glaze over. Before they could write a feature, they needed to understand &lt;code&gt;flatMap&lt;/code&gt; vs &lt;code&gt;concatMap&lt;/code&gt;, cold vs hot publishers, and why you should never call a blocking method inside a reactive chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing was painful.&lt;/strong&gt; &lt;code&gt;StepVerifier&lt;/code&gt; is a great API if you know it well. If you don't, writing a test for a &lt;code&gt;Flux&lt;/code&gt; that emits three elements and then an error is not intuitive. PRs would sit in review while engineers debated whether &lt;code&gt;verifyComplete()&lt;/code&gt; or &lt;code&gt;verify()&lt;/code&gt; was the right call to make.&lt;/p&gt;

&lt;p&gt;The business impact: slower feature delivery, a higher defect rate on reactive-specific bugs, and an engineering team that visibly preferred working on our two Spring MVC services.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture: Before and After
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph TD
  subgraph BEFORE ["Before — Spring WebFlux (Netty)"]
    A1[HTTP Request] --&amp;gt; B1[Reactive Handler]
    B1 --&amp;gt;|Mono flatMap| C1[R2DBC Query]
    B1 --&amp;gt;|Mono flatMap| D1[WebClient HTTP]
    C1 --&amp;gt;|zip| E1[Operator Chain]
    D1 --&amp;gt;|zip| E1
    E1 --&amp;gt;|map/filter| F1[Response]
    G1[2 Netty Event Loop Threads] -.-&amp;gt;|shared| B1
    G1 -.-&amp;gt;|shared| C1
    G1 -.-&amp;gt;|shared| D1
  end

  subgraph AFTER ["After — Spring MVC + Virtual Threads (Tomcat)"]
    A2[HTTP Request] --&amp;gt; B2[Blocking Handler]
    B2 --&amp;gt;|direct call| C2[JDBC Query]
    B2 --&amp;gt;|direct call| D2[RestClient HTTP]
    C2 --&amp;gt; E2[Plain Java Result]
    D2 --&amp;gt; E2
    E2 --&amp;gt; F2[Response]
    G2[Virtual Thread per Request] -.-&amp;gt;|1:1| B2
  end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The reactive model uses a small fixed pool of event loop threads that never block — hence the complexity of the operator chains. Virtual threads let you write blocking code while the JVM parks the virtual thread during I/O instead of burning an OS thread. You get the same concurrency benefit without the reactive programming model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code Difference
&lt;/h2&gt;

&lt;p&gt;Here's the same endpoint — fetching an order with its line items from DB and enriching it with real-time pricing from a downstream service — in both models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebFlux version:&lt;/strong&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;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders/{id}"&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;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;EnrichedOrder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&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="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;switchIfEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Mono&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flatMap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;lineItemRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByOrderId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;collectList&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flatMap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="n"&gt;pricingClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCurrentPrices&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                            &lt;span class="n"&gt;items&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="nl"&gt;LineItem:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getSkuId&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;toList&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;prices&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;EnrichedOrder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prices&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="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="nl"&gt;ResponseEntity:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onErrorResume&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderNotFoundException&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="n"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;just&lt;/span&gt;&lt;span class="o"&gt;(&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;notFound&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onErrorResume&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;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&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;"Failed to fetch order {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&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="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;just&lt;/span&gt;&lt;span class="o"&gt;(&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;internalServerError&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;build&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;&lt;strong&gt;Virtual threads version:&lt;/strong&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;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders/{id}"&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;EnrichedOrder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;OrderNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;LineItem&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineItemRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByOrderId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;prices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pricingClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCurrentPrices&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;items&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="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;LineItem:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getSkuId&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toList&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;ok&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnrichedOrder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prices&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 WebFlux version: 25 lines, five operator types, three indentation levels, two error handlers. The virtual threads version: 10 lines, sequential flow, a single &lt;code&gt;orElseThrow&lt;/code&gt;. The &lt;code&gt;@ControllerAdvice&lt;/code&gt; handles exceptions globally — no per-endpoint error wiring.&lt;/p&gt;

&lt;p&gt;This is the same logic. The same I/O operations. The same result. One version is readable by any Java developer; the other requires fluency in Reactor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;We ran load tests on all four services before and after migration. Here's the aggregate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;WebFlux&lt;/th&gt;
&lt;th&gt;Virtual Threads&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OS threads (peak load)&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;+22%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Virtual threads (peak load)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;~12,000&lt;/td&gt;
&lt;td&gt;created on demand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heap per instance&lt;/td&gt;
&lt;td&gt;340 MB&lt;/td&gt;
&lt;td&gt;290 MB&lt;/td&gt;
&lt;td&gt;−15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50 latency&lt;/td&gt;
&lt;td&gt;42 ms&lt;/td&gt;
&lt;td&gt;38 ms&lt;/td&gt;
&lt;td&gt;−10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 latency&lt;/td&gt;
&lt;td&gt;187 ms&lt;/td&gt;
&lt;td&gt;123 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−34%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput (req/s)&lt;/td&gt;
&lt;td&gt;2,100&lt;/td&gt;
&lt;td&gt;2,690&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+28%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lines of application code&lt;/td&gt;
&lt;td&gt;8,400&lt;/td&gt;
&lt;td&gt;4,960&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−41%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;[!NOTE]&lt;br&gt;
p99 improvement was the surprise. We expected similar p99 or a slight regression — instead it improved significantly. Our hypothesis: the reactive operator chain adds non-trivial scheduling overhead under high concurrency (subscriptions, emissions, backpressure signals), whereas virtual threads park cleanly on I/O and incur no overhead when I/O resolves quickly.&lt;/p&gt;

&lt;p&gt;OS thread count went &lt;em&gt;up&lt;/em&gt; slightly because Tomcat uses carrier threads to mount virtual threads — but those carrier threads are idle most of the time. Real memory stays flat.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to Migrate: The Exact Steps
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Step 1: Update &lt;code&gt;pom.xml&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Remove &lt;code&gt;spring-boot-starter-webflux&lt;/code&gt;. Add &lt;code&gt;spring-boot-starter-web&lt;/code&gt;.&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="c"&gt;&amp;lt;!-- Remove --&amp;gt;&lt;/span&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-webflux&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;span class="c"&gt;&amp;lt;!-- Add --&amp;gt;&lt;/span&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-web&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 you use R2DBC, replace it with JDBC + your driver. If you use &lt;code&gt;WebClient&lt;/code&gt;, replace it with &lt;code&gt;RestClient&lt;/code&gt; (Spring 6.1+) or keep &lt;code&gt;RestTemplate&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Enable virtual threads
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;application.properties&lt;/code&gt; (Spring Boot 3.2+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.threads.virtual.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Spring Boot automatically configures Tomcat to use virtual threads. No executor beans, no &lt;code&gt;@Async&lt;/code&gt; wiring.&lt;/p&gt;

&lt;p&gt;[!NOTE]&lt;br&gt;
Requires Java 21+. On Java 21 virtual threads are GA. On Java 24 (current LTS candidate as of 2026) there are additional performance improvements to virtual thread pinning.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3: Rewrite reactive handlers to blocking
&lt;/h3&gt;

&lt;p&gt;The pattern is mechanical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Mono&amp;lt;T&amp;gt;&lt;/code&gt; return → &lt;code&gt;T&lt;/code&gt; return&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.block()&lt;/code&gt; calls → direct method calls (now safe because virtual thread parks, not blocks)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Flux&amp;lt;T&amp;gt;&lt;/code&gt; → &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; (for batch queries)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;switchIfEmpty(Mono.error(...))&lt;/code&gt; → &lt;code&gt;.orElseThrow(...)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.onErrorResume(...)&lt;/code&gt; → &lt;code&gt;try/catch&lt;/code&gt; or &lt;code&gt;@ControllerAdvice&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WebClient&lt;/code&gt; → &lt;code&gt;RestClient&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Step 4: Replace reactive data access
&lt;/h3&gt;

&lt;p&gt;R2DBC is reactive-only. Switch to JDBC + Spring Data JPA or Spring Data JDBC. Connection pool should be HikariCP (default in Spring Boot). Set &lt;code&gt;maximum-pool-size&lt;/code&gt; to something reasonable — virtual threads multiply fast but JDBC connections don't, so set a ceiling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.datasource.hikari.maximum-pool-size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;[!WARNING]&lt;br&gt;
Do not set this to a huge number. With virtual threads you might be tempted to increase the pool size dramatically because OS threads are cheap. Don't. The database has a fixed connection limit. &lt;code&gt;maximum-pool-size=50&lt;/code&gt; means at most 50 concurrent DB operations; the rest of your virtual threads will wait efficiently. This is correct and expected behavior — virtual thread parking on a pool semaphore is free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Fix &lt;code&gt;ThreadLocal&lt;/code&gt;-dependent code
&lt;/h3&gt;

&lt;p&gt;The good news: MDC logging, Spring Security context, and Micrometer tracing all work out of the box with virtual threads — they use &lt;code&gt;ThreadLocal&lt;/code&gt; which is per-virtual-thread. No &lt;code&gt;MdcContextLifter&lt;/code&gt;, no manual context propagation. Delete all that boilerplate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Run your tests
&lt;/h3&gt;

&lt;p&gt;Your integration tests (if they hit a real database) will largely pass. Unit tests that used &lt;code&gt;StepVerifier&lt;/code&gt; need rewriting — now they're plain JUnit assertions. This is the most time-consuming part of the migration, not the application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Kept Reactive — and Why
&lt;/h2&gt;

&lt;p&gt;We did not migrate everything. Two cases where WebFlux stayed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-Sent Events endpoint.&lt;/strong&gt; We have a &lt;code&gt;/stream/events&lt;/code&gt; endpoint that pushes live updates to browser clients over SSE. &lt;code&gt;Flux&lt;/code&gt; with a &lt;code&gt;Sinks.Many&lt;/code&gt; is genuinely the right tool for this — it models a push stream naturally, handles backpressure, and integrates with Spring's SSE support. Virtual threads don't help here; the request handler needs to hold a channel open and push events asynchronously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kafka consumer pipeline.&lt;/strong&gt; One service does heavy stream processing: consume → transform → filter → aggregate → produce. This is a &lt;code&gt;Flux&lt;/code&gt;-from-Kafka pipeline with &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;groupBy&lt;/code&gt;, and &lt;code&gt;reduce&lt;/code&gt; operators. The reactive model fits the domain perfectly. Rewriting it to blocking code would require re-implementing the windowing and aggregation logic manually.&lt;/p&gt;

&lt;p&gt;The rule we arrived at: &lt;strong&gt;reactive is the right tool when your data is inherently a stream&lt;/strong&gt;. SSE, WebSocket, event sourcing, stream processing — these are genuinely reactive workloads. Request/response services that happen to do I/O are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Java 21+ confirmed on all targets&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;spring-boot-starter-webflux&lt;/code&gt; removed, &lt;code&gt;spring-boot-starter-web&lt;/code&gt; added&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;spring.threads.virtual.enabled=true&lt;/code&gt; set&lt;/li&gt;
&lt;li&gt;[ ] R2DBC replaced with JDBC (or keep R2DBC only where reactive data access is intentional)&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;WebClient&lt;/code&gt; replaced with &lt;code&gt;RestClient&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Reactive handlers rewritten to blocking (return &lt;code&gt;T&lt;/code&gt;, not &lt;code&gt;Mono&amp;lt;T&amp;gt;&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Reactor-specific error handling moved to &lt;code&gt;@ControllerAdvice&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] MDC/security/tracing boilerplate deleted (it now works by default)&lt;/li&gt;
&lt;li&gt;[ ] HikariCP pool size set explicitly&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;StepVerifier&lt;/code&gt;-based tests rewritten as plain JUnit&lt;/li&gt;
&lt;li&gt;[ ] Load test before and after — verify p99 improvement&lt;/li&gt;
&lt;li&gt;[ ] Keep reactive where the domain demands it (SSE, Kafka streams, WebSocket)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Verdict
&lt;/h2&gt;

&lt;p&gt;Virtual threads win for 80% of microservices. If your service is I/O-bound and does request/response work, the migration is worth doing. You get comparable or better performance, dramatically simpler code, and a team that can onboard new engineers in days instead of weeks.&lt;/p&gt;

&lt;p&gt;Reactive still wins at the edges: true streaming endpoints, event-driven pipelines, and situations where back-pressure is a genuine domain requirement (not just a concurrency pattern).&lt;/p&gt;

&lt;p&gt;The interesting implication: the argument for WebFlux in new greenfield services has collapsed. Unless you know you're building a streaming service, start with Spring MVC + virtual threads. You can always add a reactive endpoint later. You cannot easily un-reactive a whole service once the operator chains are everywhere.&lt;/p&gt;

&lt;p&gt;We're Java developers. We like reading code top to bottom. It turns out the JVM will let us do that again — without sacrificing throughput.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have you migrated from WebFlux to virtual threads? I'd love to hear what your numbers looked like — especially if they contradict mine. Find me on &lt;a href="https://linkedin.com/in/avaneeshyadav" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or leave a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to Stream LLM Responses in Spring AI (Server-Sent Events)</title>
      <dc:creator>Avaneesh Yadav</dc:creator>
      <pubDate>Tue, 07 Jul 2026 13:40:00 +0000</pubDate>
      <link>https://dev.to/avaneeshyadav/how-to-stream-llm-responses-in-spring-ai-server-sent-events-3bio</link>
      <guid>https://dev.to/avaneeshyadav/how-to-stream-llm-responses-in-spring-ai-server-sent-events-3bio</guid>
      <description>&lt;p&gt;When you call an LLM the normal way, the user stares at a spinner while the model generates the &lt;em&gt;entire&lt;/em&gt; answer, then the whole block appears at once. For anything longer than a sentence, that feels slow — even when the total time is identical to a streamed response. &lt;strong&gt;Streaming&lt;/strong&gt; flips the experience: tokens appear as they're generated, so the user starts reading immediately. It's the single cheapest UX win in any LLM feature, and with Spring AI it's essentially one method call.&lt;/p&gt;

&lt;p&gt;This guide covers the whole path: streaming from the &lt;code&gt;ChatClient&lt;/code&gt;, exposing it as &lt;strong&gt;Server-Sent Events (SSE)&lt;/strong&gt; in both WebFlux and classic Spring MVC, consuming it on the frontend, and the production details that trip people up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why stream at all?
&lt;/h2&gt;

&lt;p&gt;Perceived latency, mostly. A model that takes 6 seconds to produce a 300-word answer feels broken if the user waits 6 seconds for a wall of text — but feels fast if words start flowing at 400ms. Streaming also lets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Show progress and allow the user to &lt;strong&gt;cancel&lt;/strong&gt; a bad generation early.&lt;/li&gt;
&lt;li&gt;Start downstream processing (rendering markdown, highlighting code) incrementally.&lt;/li&gt;
&lt;li&gt;Reduce time-to-first-token as your primary latency metric, which is what users actually feel.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
Streaming changes &lt;em&gt;when&lt;/em&gt; tokens arrive, not the total cost or token count. You're billed the same — you're just improving the experience.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one line that streams
&lt;/h2&gt;

&lt;p&gt;Spring AI's &lt;code&gt;ChatClient&lt;/code&gt; exposes streaming through &lt;code&gt;.stream()&lt;/code&gt; instead of &lt;code&gt;.call()&lt;/code&gt;. Where &lt;code&gt;.call().content()&lt;/code&gt; returns a &lt;code&gt;String&lt;/code&gt;, &lt;code&gt;.stream().content()&lt;/code&gt; returns a reactive &lt;code&gt;Flux&amp;lt;String&amp;gt;&lt;/code&gt; that emits chunks as the model produces them.&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;@Service&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;ChatService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&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;ChatService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;chatClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&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="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;stream&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&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="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;content&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;That's the entire model-side change. Everything else is about getting that &lt;code&gt;Flux&lt;/code&gt; to the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expose it as SSE with WebFlux
&lt;/h2&gt;

&lt;p&gt;If your app uses &lt;strong&gt;Spring WebFlux&lt;/strong&gt;, a streaming endpoint is trivial — return the &lt;code&gt;Flux&lt;/code&gt; with a &lt;code&gt;text/event-stream&lt;/code&gt; content type and Spring handles the SSE framing:&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;@RestController&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;ChatController&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ChatService&lt;/span&gt; &lt;span class="n"&gt;chatService&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;ChatController&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatService&lt;/span&gt; &lt;span class="n"&gt;chatService&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;chatService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@GetMapping&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;"/api/chat/stream"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;produces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_EVENT_STREAM_VALUE&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;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestParam&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chatService&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="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;Each emitted chunk is sent to the client as its own &lt;code&gt;data:&lt;/code&gt; event the moment the model produces it. If you want explicit SSE events (with ids, event names, or heartbeats), map to &lt;code&gt;ServerSentEvent&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;@GetMapping&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;"/api/chat/stream"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;produces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_EVENT_STREAM_VALUE&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;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestParam&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chatService&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="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="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&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;h2&gt;
  
  
  No WebFlux? Use SseEmitter in Spring MVC
&lt;/h2&gt;

&lt;p&gt;Plenty of enterprise apps are still on the classic servlet stack. You don't need to migrate to WebFlux — Spring MVC's &lt;code&gt;SseEmitter&lt;/code&gt; streams fine. You subscribe to the &lt;code&gt;Flux&lt;/code&gt; and push each chunk onto the emitter, completing (or erroring) it when the stream ends:&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;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/chat/stream"&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;SseEmitter&lt;/span&gt; &lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestParam&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;SseEmitter&lt;/span&gt; &lt;span class="n"&gt;emitter&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;SseEmitter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toMillis&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="n"&gt;chatService&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="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;emitter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SseEmitter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&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;IOException&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;emitter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completeWithError&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="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;},&lt;/span&gt;
            &lt;span class="nl"&gt;emitter:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;completeWithError&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// onError&lt;/span&gt;
            &lt;span class="nl"&gt;emitter:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;complete&lt;/span&gt;             &lt;span class="c1"&gt;// onComplete&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;emitter&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;blockquote&gt;
&lt;p&gt;[!TIP]&lt;br&gt;
Give the &lt;code&gt;SseEmitter&lt;/code&gt; an explicit timeout. The default can hang a servlet thread indefinitely if the client disappears mid-stream.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Consume the stream on the frontend
&lt;/h2&gt;

&lt;p&gt;For a &lt;code&gt;GET&lt;/code&gt; endpoint, the browser's built-in &lt;code&gt;EventSource&lt;/code&gt; is the simplest consumer. Concatenate the chunks as they arrive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;streamChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onDone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/api/chat/stream?message=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&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;EventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// SSE closes with an error event when the server finishes the stream&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;onDone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;streamChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Explain vector databases in one paragraph.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need &lt;code&gt;POST&lt;/code&gt; (to send a large body or headers), &lt;code&gt;EventSource&lt;/code&gt; won't work — use &lt;code&gt;fetch&lt;/code&gt; with a streaming reader instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/chat/stream&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoder&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;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// you'll get raw SSE `data:` frames here — strip the prefixes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The flow, end to end
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant B as Browser
    participant C as Controller (SSE)
    participant S as ChatClient
    participant L as LLM Provider
    B-&amp;gt;&amp;gt;C: GET /api/chat/stream?message=...
    C-&amp;gt;&amp;gt;S: prompt().user(msg).stream().content()
    S-&amp;gt;&amp;gt;L: streaming request
    loop each token
        L--&amp;gt;&amp;gt;S: token
        S--&amp;gt;&amp;gt;C: Flux emits chunk
        C--&amp;gt;&amp;gt;B: data: chunk
    end
    L--&amp;gt;&amp;gt;S: [stream end]
    C--&amp;gt;&amp;gt;B: connection closes&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Production details that actually bite
&lt;/h2&gt;

&lt;p&gt;Streaming works in a demo on the first try. These are the things that break it in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Proxy buffering.&lt;/strong&gt; Nginx and many API gateways buffer responses by default, which defeats streaming — the client gets everything at once. Disable it for the stream route (&lt;code&gt;proxy_buffering off;&lt;/code&gt; in Nginx) and send &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression.&lt;/strong&gt; Response compression can also buffer the stream. Exclude &lt;code&gt;text/event-stream&lt;/code&gt; from gzip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancellation.&lt;/strong&gt; When the user navigates away, the browser closes the connection. In WebFlux this cancels the &lt;code&gt;Flux&lt;/code&gt; automatically (and stops the upstream LLM call); with &lt;code&gt;SseEmitter&lt;/code&gt;, wire an &lt;code&gt;onCompletion&lt;/code&gt;/&lt;code&gt;onTimeout&lt;/code&gt; callback to dispose the subscription so you're not billed for tokens nobody will read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors mid-stream.&lt;/strong&gt; Once the first byte is sent you can't change the HTTP status. Emit a final SSE event with an error payload (e.g. &lt;code&gt;event: error&lt;/code&gt;) so the client can distinguish "done" from "failed."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilience.&lt;/strong&gt; LLM calls are flaky network calls. Apply timeouts and a fallback path — see &lt;a href="https://dev.to/blog/ai-system-resilience"&gt;AI system resilience&lt;/a&gt; for the patterns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS.&lt;/strong&gt; If your frontend is on a different origin, SSE still needs the usual CORS headers on the streaming route.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does streaming reduce token cost?&lt;/strong&gt;&lt;br&gt;
No — you're billed for the same input/output tokens. Streaming only changes when they arrive, improving perceived latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I have to switch to WebFlux to stream?&lt;/strong&gt;&lt;br&gt;
No. &lt;code&gt;SseEmitter&lt;/code&gt; on classic Spring MVC streams a &lt;code&gt;Flux&lt;/code&gt; fine. WebFlux is cleaner if you're already reactive, but it's not required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does the client get everything at once instead of token-by-token?&lt;/strong&gt;&lt;br&gt;
Almost always a buffering proxy (Nginx/gateway) or response compression. Disable buffering and exclude &lt;code&gt;text/event-stream&lt;/code&gt; from gzip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I stream structured output (JSON) too?&lt;/strong&gt;&lt;br&gt;
You can stream the raw text, but partial JSON isn't parseable until it's complete. For structured responses, prefer &lt;code&gt;.call().entity(...)&lt;/code&gt; unless you specifically want to render tokens as they arrive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop the LLM call when the user cancels?&lt;/strong&gt;&lt;br&gt;
In WebFlux, closing the connection cancels the &lt;code&gt;Flux&lt;/code&gt; and the upstream request automatically. With &lt;code&gt;SseEmitter&lt;/code&gt;, dispose the reactive subscription in an &lt;code&gt;onCompletion&lt;/code&gt;/&lt;code&gt;onTimeout&lt;/code&gt; handler.&lt;/p&gt;




&lt;p&gt;Streaming is the highest-leverage UX change you can make to an LLM feature, and Spring AI reduces it to swapping &lt;code&gt;.call()&lt;/code&gt; for &lt;code&gt;.stream()&lt;/code&gt;. Get the endpoint right, disable proxy buffering, handle cancellation, and your app will &lt;em&gt;feel&lt;/em&gt; dramatically faster for zero extra cost.&lt;/p&gt;

&lt;p&gt;New to Spring AI? Start with the &lt;a href="https://dev.to/learning/spring-ai-overview"&gt;Spring AI overview&lt;/a&gt;, then see &lt;a href="https://dev.to/blog/tool-calling-with-spring-ai"&gt;tool calling&lt;/a&gt; and the &lt;a href="https://dev.to/blog/spring-ai-enterprise-guide"&gt;enterprise Spring AI guide&lt;/a&gt;. Planning capacity and cost for a streaming feature? Estimate prompt size with the free &lt;a href="https://dev.to/tools/llm-token-counter"&gt;LLM Token Counter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building a production AI feature and want a second pair of eyes on the architecture? &lt;a href="https://dev.to/work-with-me"&gt;Let's talk.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springai</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
