<?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: srinivas reddy gouru</title>
    <description>The latest articles on DEV Community by srinivas reddy gouru (@srinivas_gouru_d26dc31f21).</description>
    <link>https://dev.to/srinivas_gouru_d26dc31f21</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%2F3952982%2F742e0569-8b20-4ec8-8f6c-acb226355f7f.png</url>
      <title>DEV Community: srinivas reddy gouru</title>
      <link>https://dev.to/srinivas_gouru_d26dc31f21</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/srinivas_gouru_d26dc31f21"/>
    <language>en</language>
    <item>
      <title>How a Self-Learning Router Can Cut Your AI Bill</title>
      <dc:creator>srinivas reddy gouru</dc:creator>
      <pubDate>Thu, 22 Oct 2026 17:02:41 +0000</pubDate>
      <link>https://dev.to/srinivas_gouru_d26dc31f21/how-a-self-learning-router-can-cut-your-ai-bill-4ol3</link>
      <guid>https://dev.to/srinivas_gouru_d26dc31f21/how-a-self-learning-router-can-cut-your-ai-bill-4ol3</guid>
      <description>&lt;h2&gt;
  
  
  Pick the right model for every request by learning from outcomes: scoring, Thompson sampling, a safe architecture, guardrails, and an honest list of pros and cons.
&lt;/h2&gt;

&lt;p&gt;Most teams pick one LLM, hardcode its name, and pay for that choice on every single request. Some go further and write rules: "send 70 percent here, 30 percent there", "use the cheap model unless it is down". Then a vendor changes its prices, a model quietly gets worse after an update, or the traffic shifts from chat to code, and the rule keeps choosing exactly as before. Nobody is told.&lt;/p&gt;

&lt;p&gt;This article explains a different approach: &lt;strong&gt;routing that learns from outcomes&lt;/strong&gt;. The gateway in front of your models scores every response, keeps a belief about how well each model does for each kind of work, and lets those beliefs choose the next model. It can send most traffic to the cheapest model that is doing the job, and keep checking that it still is.&lt;/p&gt;

&lt;p&gt;The idea is simple. Making it safe, honest and useful is most of the work, and that is what this piece walks through, including the parts that do not work as well as the idea suggests.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Score every response on what it cost (speed, price, completeness) and, when you can measure it, whether it was right.&lt;/li&gt;
&lt;li&gt;Keep a &lt;em&gt;belief&lt;/em&gt; per model, per customer and per type of work, not a single average.&lt;/li&gt;
&lt;li&gt;Choose with &lt;strong&gt;Thompson sampling&lt;/strong&gt;: draw a plausible score from each belief and pick the highest. Exploration comes for free.&lt;/li&gt;
&lt;li&gt;Keep the learner off the request path, and never let it choose a model the caller is not allowed to use.&lt;/li&gt;
&lt;li&gt;The catch: without a quality signal it optimises cost and speed, not correctness. A fast, cheap, confidently wrong model can win.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;All numbers and model names in this article are illustrative, chosen to explain the mechanism.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;On a railway, the points are the movable rails at a junction. Whoever sets them decides which track a train takes next. A gateway that sits in front of several language models does the same job thousands of times a minute: one request arrives, several models could answer it, and something has to throw the switch.&lt;/p&gt;

&lt;p&gt;Most teams now have more than one model that can serve a request, and the candidates differ in ways that matter: price per token, speed, how often they time out, and how good they are at a particular kind of task. A model that is excellent at writing code may be mediocre at factual questions. A model that is cheap today may be repriced next month.&lt;/p&gt;

&lt;p&gt;The usual answer is a fixed rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Priority:&lt;/strong&gt; always use the first model and keep the others for failover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weighted split:&lt;/strong&gt; send 70 percent here and 30 percent there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round robin:&lt;/strong&gt; rotate evenly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fastest or cheapest:&lt;/strong&gt; pick whichever scores best on one live measurement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is a judgement frozen at the moment someone wrote it down. None of them hears back about how its choices turned out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adaptive routing closes the loop: score every response, update a belief, and let the next choice follow the beliefs.&lt;/strong&gt; Everything below is about making that loop trustworthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five words to know
&lt;/h2&gt;

&lt;p&gt;The technique comes from a branch of statistics called &lt;em&gt;multi-armed bandits&lt;/em&gt;, named after a row of slot machines ("one-armed bandits"), each paying out at an unknown rate. You learn the rates only by pulling the levers, and every pull spent learning is a pull not spent on the machine you currently think is best.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Arm:&lt;/strong&gt; one thing the router can choose, usually a provider and model pair.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reward:&lt;/strong&gt; a score between 0 and 1 that says how well one response went.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Belief:&lt;/strong&gt; a range of plausible values for an arm's typical reward, not a single number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy:&lt;/strong&gt; the rule that turns beliefs into a choice for the next request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exploration:&lt;/strong&gt; choosing an arm you are less sure about, to learn whether it is better than it looks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Always picking the arm that looks best right now means you never find out whether another arm is better. Picking at random learns a lot but wastes traffic. A good policy balances the two automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring a response
&lt;/h2&gt;

&lt;p&gt;Before anything can be learned, the router needs to decide what a good response is worth. This is the most important design decision in the whole system, because the router will optimise exactly what the reward measures and nothing else.&lt;/p&gt;

&lt;p&gt;Think of two sides of the same response:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The consumption side:&lt;/strong&gt; what the answer cost to get. Latency against a target, cost compared with the other candidates that were available for this request, and a check that the response was complete rather than truncated or malformed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The answer side:&lt;/strong&gt; whether the answer was actually good. This comes from feedback, a validator, or a judge model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The consumption side is always available, because the gateway measures it itself. The answer side is only available when someone tells the gateway, and that asymmetry shapes everything that follows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules that keep the score honest
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Score cost relative to what was on the table.&lt;/strong&gt; "Expensive" should mean expensive compared with the other candidates at this request's actual size. A long prompt is costly on every model; that is not the model's fault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave missing signals out.&lt;/strong&gt; If a provider did not report token counts, that check is absent from the score, not counted as zero. Treating "unknown" as "bad" punishes requests for merely lacking a signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failure scores zero, full stop.&lt;/strong&gt; Otherwise a model that fails &lt;em&gt;quickly&lt;/em&gt; would look attractive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learn cost only from trustworthy numbers.&lt;/strong&gt; If the usage a provider reported is inconsistent, or had to be estimated, or the response came from a different model than the one you asked for, count it for reliability but do not learn anything about price from it. A router that learns from bad bills will happily prefer whichever model reports its usage incorrectly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  One knob: how much quality is worth
&lt;/h3&gt;

&lt;p&gt;When both sides are present, a single preference decides how they combine. At a preference of 0.3, a rated response scores 0.3 times its answer side plus 0.7 times its consumption side. Low values favour efficiency; high values favour correctness.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3jc029cven26t1uivkw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3jc029cven26t1uivkw.png" alt="Line chart: the composed score of a fast, inexpensive, 82% accurate model falls slowly as the quality preference rises, while a slow, costly, 95% accurate model rises steeply; they cross near a preference of 0.84" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Model A is fast and inexpensive with good accuracy. Model B is slower, costs far more, and is more accurate. Below a preference of about 0.84 the router prefers A, and that is correct: it was asked to weight efficiency, and it did. This is where the cost saving comes from.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two consequences are easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unrated traffic is scored on consumption alone, whatever the preference.&lt;/strong&gt; If nothing ever tells the router whether answers were right, a preference of 0.9 routes exactly like 0.1. Raising the knob without supplying a quality signal changes nothing, so watch the share of traffic that actually receives a rating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late feedback needs its own weight.&lt;/strong&gt; A rating usually arrives after the response was already scored. It travels as a separate observation, weighted by &lt;code&gt;p / (1 − p)&lt;/code&gt; where p is the preference: at 0.3 a rating counts as 0.43 of an observation, at 0.75 it counts as three. Multiplied by a feedback rate limit, the same weight caps how much any one client can steer the router.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why default to efficiency?&lt;/strong&gt; The two possible mistakes are not symmetric. A default that leans too cheap produces answers someone notices and dials up. A default that leans too expensive produces a bill nobody can undo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beliefs, not averages
&lt;/h2&gt;

&lt;p&gt;An average hides how much evidence stands behind it. A model that scored 0.9 on five requests and a model that scored 0.9 on five thousand are not the same finding.&lt;/p&gt;

&lt;p&gt;A common way to keep both pieces of information is the &lt;strong&gt;Beta distribution&lt;/strong&gt;, described by two numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;α (alpha):&lt;/strong&gt; roughly, the good outcomes seen so far, plus one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;β (beta):&lt;/strong&gt; roughly, the bad outcomes seen so far, plus one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rewards are fractional, so a response that scored 0.9 adds 0.9 to α and 0.1 to β. The ratio &lt;code&gt;α / (α + β)&lt;/code&gt; is the typical score. The sum &lt;code&gt;α + β&lt;/code&gt; is the confidence: it grows with every observation, and the belief narrows as it grows. Every arm starts at α = 1, β = 1, a flat belief, because nothing has been seen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpoovp3w6b50y8qg14ll.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpoovp3w6b50y8qg14ll.png" alt="Three belief curves: a tall narrow curve for Model A around 0.86 (about 100 observations), a wider curve for Model B around 0.74 (about 17 observations), and a broad flat curve for Model C around 0.38 (6 observations)" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Model C's centre looks poor, but six observations are close to a coin toss, and the width of its curve says so. Two arms can show the same mean and mean very different things: a narrow curve is a measurement, a broad one is a guess.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Thompson sampling: let uncertainty pay for exploration
&lt;/h2&gt;

&lt;p&gt;The tempting policy is to pick the arm with the highest mean. It is wrong for a simple reason: an arm you stop choosing stops producing evidence, and its belief freezes wherever it happened to be. If it was unlucky early, you will never find out it was good.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thompson sampling&lt;/strong&gt; fixes this in one step. For each candidate, draw one random value from its belief. Route the request to whichever arm drew the highest value.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A narrow belief draws values close to its mean almost every time.&lt;/li&gt;
&lt;li&gt;A wide belief sometimes draws high, so an uncertain arm occasionally wins on the strength of its uncertainty.&lt;/li&gt;
&lt;li&gt;When it wins, it gets traffic and its belief narrows. If it really is better, it starts winning on its mean; if not, it fades.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mg3q9qh3k8h3yj5lhen.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mg3q9qh3k8h3yj5lhen.png" alt="Bar chart of 10,000 simulated requests: Model A wins 89.1 percent, Model B 10.8 percent, Model C 0.1 percent" width="800" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;10,000 simulated requests drawn from the three beliefs above. Model B still gets about one request in nine, because its wide belief often reaches past A's. Model C almost never wins: its belief is wide but sits mostly below A's. Uncertainty buys traffic only when the plausible range overlaps the leader's.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Exploration is not a separate mode switched on and off. It falls out of drawing from a width, and it shrinks as evidence accumulates. The draw costs a couple of random numbers per candidate, computed inside the gateway with no network call.&lt;/p&gt;

&lt;p&gt;Log the probability each chosen arm had of being chosen. That small detail unlocks &lt;strong&gt;off-policy evaluation&lt;/strong&gt;: replaying past traffic to estimate what a different policy or reward would have earned, before you ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Learning about the right thing
&lt;/h2&gt;

&lt;p&gt;A model that is excellent at code and average at trivia has no single score, and a belief that averages the two is wrong about both. So beliefs are kept per &lt;strong&gt;sampling key&lt;/strong&gt;, typically the customer joined to a task type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per customer&lt;/strong&gt;, so one tenant's traffic never teaches the router about another's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per task type&lt;/strong&gt; (chat, code, summarise, extract, reasoning, factual knowledge), so models are compared on like-for-like work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The task type can come from the caller, who usually knows what a request is, or from a cheap classifier. Keep the vocabulary closed: every distinct value becomes a set of beliefs the learner must store, so letting callers invent labels lets them grow its memory without bound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong when workloads are mixed.&lt;/strong&gt; Imagine Model X is clearly best at factual recall, Model Y is clearly best at commonsense reasoning, and Model Z is decent at both and cheap. If both kinds of traffic share one belief, the averages wash out each specialist's strength and Z can take most of the traffic. Split the work into separate keys and X and Y each win where they are strong. Nothing about the models changed; only the key did. Telling the router what kind of work a request is may be the single most effective thing you can do for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contextual bandits&lt;/strong&gt; go further and condition the belief on features of the request itself, such as prompt length or the presence of code. A popular variant, &lt;em&gt;linear Thompson sampling&lt;/em&gt;, keeps a belief over a small linear model of reward instead of one number per arm. It captures patterns that task labels miss, at the price of needing more data and being harder to explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cold starts, curiosity and change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Starting cold.&lt;/strong&gt; A new customer or task type has no evidence, so the first decisions are effectively coin flips. Make it visible: label each decision as &lt;em&gt;learned&lt;/em&gt;, &lt;em&gt;cold&lt;/em&gt; or &lt;em&gt;exploration&lt;/em&gt;, so a dashboard can never present degraded routing as learned routing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Starting warm.&lt;/strong&gt; Beliefs can be &lt;em&gt;seeded&lt;/em&gt; from an offline evaluation, as a prior mean worth a stated number of observations. A mean of 0.9 at a strength of 10 becomes α = 10, β = 2: a belief that leans, but that ten real observations can move. Keep the strength low, because a benchmark measures a different corpus at a different moment, and report seeded and live evidence separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never fully giving up.&lt;/strong&gt; An arm with a thousand bad observations will practically never draw high again, which is correct until the vendor fixes the model. A small &lt;em&gt;exploration floor&lt;/em&gt;, say 5 percent of decisions picked at random, keeps a recovered arm findable. It is shared, though: with five arms, each losing arm gets about one percent, and every new arm thins every share.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the world moves.&lt;/strong&gt; A belief built on thousands of old observations moves slowly, so a router can keep avoiding an arm long after it recovered, or keep favouring one after it got worse. Gradual forgetting, change detection that resets a belief when the reward stream shifts, and the exploration floor all help. Each adds tuning: forget too quickly and the router chases noise; too slowly and it lives in the past.&lt;/p&gt;

&lt;h2&gt;
  
  
  An architecture that cannot fail traffic
&lt;/h2&gt;

&lt;p&gt;The design choice that matters most is to separate the part that &lt;strong&gt;chooses&lt;/strong&gt; from the part that &lt;strong&gt;learns&lt;/strong&gt;, and let trust flow in one direction only.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbiihwojy16vc1vvne37q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbiihwojy16vc1vvne37q.png" alt="Diagram: on the request path, Request flows to Eligibility, Sampler, Model and Score inside the gateway. Score sends rewards asynchronously to a separate learner process that updates beliefs; the gateway polls the published beliefs back into the sampler every few seconds." width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sampling happens locally.&lt;/strong&gt; The gateway holds the latest published beliefs in memory and draws from them in-process. There is no synchronous "decide" call to fail or add latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The learner is polled, not called.&lt;/strong&gt; If a poll fails, the gateway keeps what it had. If the learner stops entirely, routing continues on the last beliefs it published (or flat ones), and only learning pauses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gateway mints the decision id.&lt;/strong&gt; The eventual reward carries the decision, the key and the arm that served, so attribution is structural and nothing can go stale on the learner side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One writer for beliefs.&lt;/strong&gt; Several learner workers holding beliefs in memory drift apart. Run a single writer, and refuse to start otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two implementations must agree.&lt;/strong&gt; If the gateway and the learner are written in different languages, test statistically that the gateway's sampler draws like the learner's. A sampler that draws wrongly explores wrongly, and invisibly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Guardrails around the learner
&lt;/h2&gt;

&lt;p&gt;The most important rule: &lt;strong&gt;the learner may choose, but it may never widen what is allowed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eligibility comes first.&lt;/strong&gt; Before the sampler sees a candidate, everything that may not or cannot serve this request is removed: models the caller's key is not allowed to use, models missing a needed capability or with too small a context window, providers with an open circuit breaker or in a rate-limit cooldown, and anything that would break a budget. A belief, however confident, can never bring back a candidate a filter removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure handling comes after.&lt;/strong&gt; If the chosen model fails, a fallback ladder re-checks live health at every step: the same model elsewhere, declared fallbacks, the same family or tier, then a tier up or down. The learner changes the &lt;em&gt;order&lt;/em&gt; in which healthy candidates are tried, never the permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Composing with everything else:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conversation stickiness.&lt;/strong&gt; Providers bill a repeated prompt prefix at a large discount, but only when consecutive turns land on the same provider. Pinning a conversation saves money, with an honest trade-off: it will not move to a newly preferred model until the pin lapses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deprecation as a weight.&lt;/strong&gt; Multiply a retiring model's sampled score down at selection time instead of punishing its reward, so undoing it is instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust the evidence before learning from it.&lt;/strong&gt; Check that the model that answered is the one you dispatched, validate reported usage, and keep unverified answers out of cost learning and out of any response cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A stricter sibling: proven quality
&lt;/h2&gt;

&lt;p&gt;Learning on live traffic suits workloads where an occasional weaker answer during exploration is acceptable. Some cannot accept that. For those, &lt;strong&gt;quality-guarded routing&lt;/strong&gt; serves a cheaper model only where there is statistical evidence it is good enough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The evaluation is fully specified before anyone looks at results: task set, rubric, baseline, required quality and margin, confidence level.&lt;/li&gt;
&lt;li&gt;Results are judged with exact confidence bounds, corrected for testing several models at once, so a model qualifies only if even the pessimistic end of its range clears the bar.&lt;/li&gt;
&lt;li&gt;Evidence is bound to the exact model version and expires. If the model behind a name changes, qualification lapses.&lt;/li&gt;
&lt;li&gt;Among qualified models the cheapest wins; with none qualified, a declared baseline serves. Never a random model.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Learning router (bandit)&lt;/th&gt;
&lt;th&gt;Quality-guarded router&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Learns from&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every live response, continuously&lt;/td&gt;
&lt;td&gt;A sealed offline evaluation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Optimises&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A blend of cost, speed and quality&lt;/td&gt;
&lt;td&gt;Price, among models proven good enough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Explores on live traffic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes, by design&lt;/td&gt;
&lt;td&gt;No, or only among qualified models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Without evidence&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Coin flips among eligible models&lt;/td&gt;
&lt;td&gt;The declared baseline, or a clear refusal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reacts to change&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Automatically, with some lag&lt;/td&gt;
&lt;td&gt;Only when a new evaluation runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High volume, measurable outcomes&lt;/td&gt;
&lt;td&gt;Regulated or contractual quality bars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Advantages
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It adapts without anyone editing configuration.&lt;/strong&gt; Repricing, a quiet regression, a new model in the group: the scores notice and traffic shifts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can cut cost where cost is safe to cut.&lt;/strong&gt; At a low quality preference, the cheapest model that is measurably doing the job takes most of the traffic, and keeps being re-checked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It learns from your traffic, not a leaderboard.&lt;/strong&gt; Beliefs per customer and per task mean it learns which model works for &lt;em&gt;your&lt;/em&gt; code and &lt;em&gt;your&lt;/em&gt; summaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trade-offs become explicit.&lt;/strong&gt; One preference states how much quality is worth against cost and speed, per task if needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exploration is built in and bounded.&lt;/strong&gt; No separate A/B test to plan and dismantle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not overreact to small samples.&lt;/strong&gt; Five lucky responses do not crown a model, because beliefs carry their width.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is safe by construction.&lt;/strong&gt; Choosing is local and fast; a learner failure pauses learning and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decisions are explainable.&lt;/strong&gt; Each choice records its beliefs, its probability, and whether it was learned, cold or exploratory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It enables "what if" questions.&lt;/strong&gt; Logged probabilities let you evaluate a new reward or policy offline before going live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It gives feedback somewhere to go.&lt;/strong&gt; A static router has nowhere to put a quality signal.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Disadvantages and limits
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Without a quality signal, it optimises efficiency, not correctness.&lt;/strong&gt; A fast, cheap, well-formed and confidently wrong model scores well on everything a gateway can measure by itself. This is the most dangerous failure mode, and it is invisible unless you watch the rated share of traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Good quality signals are slow, sparse or costly.&lt;/strong&gt; Machine-checkable outcomes are ideal but rare. A judge model costs money per judgement, and the bill grows with the number of models and how small a difference you want to detect, not with traffic volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exploration costs real requests.&lt;/strong&gt; Some users get answers from models the router already suspects are worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start is a coin flip.&lt;/strong&gt; Seeding helps only as far as offline data resembles real traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confident beliefs are slow to change.&lt;/strong&gt; A recovered model can keep losing; a regressed favourite can keep winning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The exploration floor thins as models are added.&lt;/strong&gt; Each losing arm's share shrinks, and noticing a recovery takes longer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choosing the key is a balancing act.&lt;/strong&gt; Too coarse averages away real differences; too fine starves each belief of evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing is no longer deterministic.&lt;/strong&gt; The same request can go to different models on different days, which complicates debugging and consistency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reward is a value judgement.&lt;/strong&gt; The router optimises exactly what it is told to, including a badly chosen latency target or cost weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feedback can be gamed.&lt;/strong&gt; Weighting, rate limits and per-tenant keys have to be designed in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is more to operate.&lt;/strong&gt; A second process, a belief store, a single-writer rule, parity tests and dashboards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It competes with prompt caching.&lt;/strong&gt; Moving a conversation to a newly preferred model forfeits the cached-prefix discount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest evaluation is hard.&lt;/strong&gt; Re-scoring old responses shows the direction of a change; a live run would collect different evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untrustworthy data must be thrown away.&lt;/strong&gt; Inconsistent or unverified usage cannot teach cost, so the router learns from fewer observations than it serves.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When to use what
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;A sensible choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One approved model, or a mandate on which model serves&lt;/td&gt;
&lt;td&gt;Fixed routing&lt;/td&gt;
&lt;td&gt;Nothing to learn; predictability is the requirement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A canary or an A/B test&lt;/td&gt;
&lt;td&gt;Weighted random split&lt;/td&gt;
&lt;td&gt;The split you declare is the split you get&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High volume with machine-checkable outcomes&lt;/td&gt;
&lt;td&gt;Learning router with feedback&lt;/td&gt;
&lt;td&gt;Free, accurate quality signals and plenty of evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open-ended chat with no quality signal&lt;/td&gt;
&lt;td&gt;Learning router at a low preference, or a latency/cost rule&lt;/td&gt;
&lt;td&gt;It will optimise efficiency; say so&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Very different kinds of work through one endpoint&lt;/td&gt;
&lt;td&gt;Learning router with task types, or a contextual bandit&lt;/td&gt;
&lt;td&gt;Specialists are not averaged away&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A contractual or regulated quality bar&lt;/td&gt;
&lt;td&gt;Quality-guarded routing with a baseline&lt;/td&gt;
&lt;td&gt;Cheaper models only after statistical evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Adoption checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with comparable candidates.&lt;/strong&gt; The router chooses; it should not have to rescue bad options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declare every price.&lt;/strong&gt; A model with an unknown price must not be scored as free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide your task types and send them.&lt;/strong&gt; The cheapest large improvement to belief quality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with a low quality preference.&lt;/strong&gt; Raise it per task only where a quality signal actually arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire up the cheapest honest quality signal.&lt;/strong&gt; Parsers, schema checks, test runs and label matches beat judges wherever they apply.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Seed lightly, if at all.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the right dashboards.&lt;/strong&gt; Decision provenance, traffic share per model, belief width, and the rated fraction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for change.&lt;/strong&gt; Choose an exploration floor and a forgetting policy deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluate before you re-tune.&lt;/strong&gt; Use logged probabilities to test a new reward offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the guardrails non-negotiable.&lt;/strong&gt; Eligibility before choice, fallback after it, and no path by which the learner can grant access.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The claim, without the marketing
&lt;/h2&gt;

&lt;p&gt;Out of the box, a learning router is an &lt;strong&gt;efficiency-optimising&lt;/strong&gt; router. For each customer and each kind of work it learns which model is fast, affordable and reliable, and it keeps relearning as those facts change. That alone beats a weight someone typed once and forgot, and it is where most of the savings come from.&lt;/p&gt;

&lt;p&gt;Where you can say what a good answer is, and tell the router, it becomes an &lt;strong&gt;outcome-optimising&lt;/strong&gt; router, and the quality preference states how much that definition is allowed to cost. Where a quality bar must be proven before anyone relies on it, a guarded router built on sealed evaluations is the better tool.&lt;/p&gt;

&lt;p&gt;The switch at the junction was never the hard part. Knowing which track was right, and admitting how sure you are, is the whole job.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Are you routing between several LLMs today? I would like to hear how you decide, and what has burned you, in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>The equals and hashCode contract in Java: Failure modes and the TreeSet twist</title>
      <dc:creator>srinivas reddy gouru</dc:creator>
      <pubDate>Thu, 22 Oct 2026 16:11:21 +0000</pubDate>
      <link>https://dev.to/srinivas_gouru_d26dc31f21/the-equals-and-hashcode-contract-in-java-failure-modes-and-the-treeset-twist-3p3l</link>
      <guid>https://dev.to/srinivas_gouru_d26dc31f21/the-equals-and-hashcode-contract-in-java-failure-modes-and-the-treeset-twist-3p3l</guid>
      <description>&lt;h2&gt;
  
  
  1. The one idea to hold onto
&lt;/h2&gt;

&lt;p&gt;Forget the formal wording for a moment and hold this picture instead.&lt;/p&gt;

&lt;p&gt;Think of an object as a person's full name, like "Srinivas Reddy". Think of&lt;br&gt;
&lt;code&gt;hashCode()&lt;/code&gt; as that person's initials, "SR": a cheap little summary you can&lt;br&gt;
compute from the full name in a second. And think of &lt;code&gt;equals()&lt;/code&gt; as the slow,&lt;br&gt;
careful check: do the two full names actually match, letter for letter?&lt;/p&gt;

&lt;p&gt;With that picture, the two clauses of the contract stop being rules and become&lt;br&gt;
common sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clause one: equal objects must have equal hash codes.&lt;/strong&gt; If two records are the&lt;br&gt;
same person, their initials must come out the same. "Srinivas Reddy" gives you&lt;br&gt;
"SR" every single time. It would be absurd for the identical name to produce&lt;br&gt;
"SR" on one form and "TK" on another. So when the full thing is equal, any&lt;br&gt;
summary derived from it is forced to be equal too. That is just what "summary of&lt;br&gt;
the same thing" means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clause two: equal hash codes do NOT force the objects to be equal.&lt;/strong&gt; Flip it&lt;br&gt;
around. If I only tell you someone's initials are "SR", can you tell me who they&lt;br&gt;
are? No. "SR" could be Srinivas Reddy, or Sara Roy, or Sam Rivera. Same&lt;br&gt;
initials, completely different people. Matching initials is a hint that two&lt;br&gt;
records might be the same, never a proof. You still have to read the full names.&lt;br&gt;
Two different things sharing a summary has a name in the hashing world, a&lt;br&gt;
&lt;em&gt;collision&lt;/em&gt;, and it is completely normal and expected.&lt;/p&gt;

&lt;p&gt;Here is a real collision from Java you can verify yourself. The two strings&lt;br&gt;
&lt;code&gt;"Aa"&lt;/code&gt; and &lt;code&gt;"BB"&lt;/code&gt; are obviously different words, yet they produce the exact same&lt;br&gt;
&lt;code&gt;hashCode&lt;/code&gt;, the number 2112. It falls out of Java's string formula (each&lt;br&gt;
character times 31, summed):&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="s"&gt;"Aa"&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="c1"&gt;// 65*31 + 97  = 2112&lt;/span&gt;
&lt;span class="s"&gt;"BB"&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="c1"&gt;// 66*31 + 66  = 2112&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two genuinely different objects, one identical hash. That is clause two in the&lt;br&gt;
flesh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one-way street.&lt;/strong&gt; Initials are a summary, and a summary throws away almost&lt;br&gt;
all the information in the name. Once you have thrown information away, different&lt;br&gt;
things will occasionally collapse to the same summary. There simply are not&lt;br&gt;
enough initials to go around. But the act of making the summary is fixed and&lt;br&gt;
mechanical: the same name always yields the same initials, and two equal names&lt;br&gt;
always yield equal initials. So the arrow only runs one direction. Equal name&lt;br&gt;
guarantees equal initials. Equal initials guarantees nothing. Lossy, but&lt;br&gt;
consistent, and that combination is the entire asymmetry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The consistency clause.&lt;/strong&gt; Your initials today are the same as your initials&lt;br&gt;
tomorrow, unless you actually change your name. In code: as long as nothing&lt;br&gt;
about the object changes, calling &lt;code&gt;hashCode()&lt;/code&gt; again gives the same answer. It&lt;br&gt;
does not wander.&lt;/p&gt;

&lt;p&gt;If you want the single sentence that carries all of it: &lt;code&gt;hashCode&lt;/code&gt; is a cheap&lt;br&gt;
summary used to narrow down, and &lt;code&gt;equals&lt;/code&gt; is the careful check used to confirm,&lt;br&gt;
and the contract simply guarantees those two never contradict each other.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Why the contract exists: the two-step lookup
&lt;/h2&gt;

&lt;p&gt;The reason clause one is non-negotiable becomes obvious once you see how a&lt;br&gt;
&lt;code&gt;HashMap&lt;/code&gt; actually finds things. It is secretly an array of slots called&lt;br&gt;
buckets, and a lookup does two moves in sequence, never a full scan.&lt;/p&gt;

&lt;p&gt;The first move: it calls &lt;code&gt;hashCode()&lt;/code&gt; on your key and uses that number to pick&lt;br&gt;
exactly one bucket. This is the shortlist step, how the map jumps straight to&lt;br&gt;
the handful of entries that could possibly match instead of checking a million.&lt;br&gt;
The second move: inside that one bucket, it calls &lt;code&gt;equals()&lt;/code&gt; on each candidate&lt;br&gt;
to find the exact match. This is the confirmation step.&lt;/p&gt;

&lt;p&gt;So the roles are: hash narrows, equals confirms. Hash gets you to the right&lt;br&gt;
shelf; equals reads the spine to find the exact book.&lt;/p&gt;

&lt;p&gt;Now the asymmetry makes mechanical sense. Two different objects landing in the&lt;br&gt;
same bucket is fine, because &lt;code&gt;equals&lt;/code&gt; will sort them out once they are there.&lt;br&gt;
That is a collision, and collisions are expected. But two equal objects landing&lt;br&gt;
in different buckets is a catastrophe, because the confirmation step only ever&lt;br&gt;
runs inside a single bucket. If your key is on the wrong shelf, &lt;code&gt;equals&lt;/code&gt; never&lt;br&gt;
even gets a chance to run. The librarian searching shelf seven will never find&lt;br&gt;
the book sitting on shelf twelve, no matter how identical the two copies are,&lt;br&gt;
because she never walks to shelf twelve.&lt;/p&gt;

&lt;p&gt;That single sentence is the whole contract. Everything in the failure gallery&lt;br&gt;
below is just that idea showing up in different disguises.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. The contract, stated precisely
&lt;/h2&gt;

&lt;p&gt;Now that you have the picture, the formal version reads as a summary rather than&lt;br&gt;
a wall.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;hashCode&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If &lt;code&gt;a.equals(b)&lt;/code&gt; is true, then &lt;code&gt;a.hashCode() == b.hashCode()&lt;/code&gt; must be true.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;a.hashCode() == b.hashCode()&lt;/code&gt;, then &lt;code&gt;a.equals(b)&lt;/code&gt; is NOT required to be
true (collisions are legal).&lt;/li&gt;
&lt;li&gt;Called repeatedly on the same object with unchanged state, &lt;code&gt;hashCode()&lt;/code&gt; must
return the same value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For &lt;code&gt;equals&lt;/code&gt;, the method must be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reflexive:&lt;/strong&gt; &lt;code&gt;a.equals(a)&lt;/code&gt; is always true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Symmetric:&lt;/strong&gt; if &lt;code&gt;a.equals(b)&lt;/code&gt; then &lt;code&gt;b.equals(a)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transitive:&lt;/strong&gt; if &lt;code&gt;a.equals(b)&lt;/code&gt; and &lt;code&gt;b.equals(c)&lt;/code&gt; then &lt;code&gt;a.equals(c)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent:&lt;/strong&gt; repeated calls give the same result while nothing changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Null-safe:&lt;/strong&gt; &lt;code&gt;a.equals(null)&lt;/code&gt; is always false and never throws.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The examples below are, in effect, a tour of what breaks when you violate one of&lt;br&gt;
these.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. The failure gallery
&lt;/h2&gt;

&lt;p&gt;Each example is a complete, runnable snippet with the surprising output written&lt;br&gt;
as a comment, followed by the reason it happens.&lt;/p&gt;
&lt;h3&gt;
  
  
  4.1 Override equals, forget hashCode (the classic)
&lt;/h3&gt;

&lt;p&gt;This is the most common bug, and it is insidious because the code looks&lt;br&gt;
perfectly reasonable.&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;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;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;boolean&lt;/span&gt; &lt;span class="nf"&gt;equals&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;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="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;email&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&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;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// hashCode() NOT overridden&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;User&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;map&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;map&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a@b.com"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"Srinivas"&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;map&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a@b.com"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// returns null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you &lt;code&gt;put&lt;/code&gt;, the map calls &lt;code&gt;hashCode()&lt;/code&gt; on your first &lt;code&gt;User&lt;/code&gt;. You did not&lt;br&gt;
override it, so it uses the one inherited from &lt;code&gt;Object&lt;/code&gt;, which is based on the&lt;br&gt;
object's identity (essentially its memory address, unique to that specific&lt;br&gt;
object). Say that gives bucket 2, so the entry is filed there.&lt;/p&gt;

&lt;p&gt;When you &lt;code&gt;get&lt;/code&gt; with a brand new &lt;code&gt;User&lt;/code&gt; that has the same email, the map calls&lt;br&gt;
&lt;code&gt;hashCode()&lt;/code&gt; on this different object. Different object, different identity hash,&lt;br&gt;
say bucket 5. The map walks to bucket 5, finds it empty, and returns null. Your&lt;br&gt;
carefully written &lt;code&gt;equals&lt;/code&gt; is never called, because the search never reached&lt;br&gt;
bucket 2 where the entry actually lives.&lt;/p&gt;

&lt;p&gt;The entry is in the map. You just cannot get it out.&lt;/p&gt;

&lt;p&gt;The same flaw wearing a different symptom in a &lt;code&gt;HashSet&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="nc"&gt;Set&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;seen&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;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;seen&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a@b.com"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;seen&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a@b.com"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// should be a duplicate&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;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&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;// prints 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The set looked in the second object's bucket, found nothing, concluded it was&lt;br&gt;
new, and stored it. You now have two equal elements in a &lt;code&gt;Set&lt;/code&gt;. The set has&lt;br&gt;
quietly stopped being a set.&lt;/p&gt;
&lt;h3&gt;
  
  
  4.2 The mutable-key leak
&lt;/h3&gt;

&lt;p&gt;Here &lt;code&gt;hashCode&lt;/code&gt; and &lt;code&gt;equals&lt;/code&gt; are both correct, but the fields they depend on can&lt;br&gt;
change after insertion.&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;class&lt;/span&gt; &lt;span class="nc"&gt;Point&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;x&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;Point&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;x&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;y&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&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;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;;&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;boolean&lt;/span&gt; &lt;span class="nf"&gt;equals&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;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="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;Point&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;x&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;y&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;;&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;int&lt;/span&gt; &lt;span class="nf"&gt;hashCode&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;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&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="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Point&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;set&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;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Point&lt;/span&gt; &lt;span class="n"&gt;p&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;Point&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&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;set&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;p&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// hash computed from (1,1), filed in that bucket&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;// mutate the key AFTER inserting it&lt;/span&gt;
&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&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="c1"&gt;// false, even though p is physically in the set&lt;/span&gt;
&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remove&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="c1"&gt;// also fails to remove it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you added &lt;code&gt;p&lt;/code&gt;, its hash was computed from &lt;code&gt;(1, 1)&lt;/code&gt; and it was filed in the&lt;br&gt;
matching bucket. Then you changed &lt;code&gt;x&lt;/code&gt; to 99, so the hash is now computed from&lt;br&gt;
&lt;code&gt;(99, 1)&lt;/code&gt;, which points at a different bucket. Every future lookup computes the&lt;br&gt;
new hash and searches the wrong bucket. The object is stranded in its old&lt;br&gt;
bucket, unreachable and unremovable.&lt;/p&gt;

&lt;p&gt;That is not a metaphor. It is a genuine memory leak: the entry occupies space,&lt;br&gt;
cannot be found, and cannot be deleted. This is the strongest argument for&lt;br&gt;
making keys and set elements immutable.&lt;/p&gt;
&lt;h3&gt;
  
  
  4.3 A legal but terrible hashCode
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Override&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;hashCode&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="mi"&gt;42&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// constant&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Here is the subtle part: this does NOT violate the contract. Equal objects do&lt;br&gt;
return equal hashes, because every object returns 42. So lookups are correct.&lt;br&gt;
They are just slow. Every entry now hashes to the same bucket, so instead of&lt;br&gt;
jumping to a shelf with one or two items, the map dumps everything into one&lt;br&gt;
giant bucket and walks the whole thing calling &lt;code&gt;equals&lt;/code&gt;. Your O(1) map has&lt;br&gt;
degraded toward O(n).&lt;/p&gt;

&lt;p&gt;Modern Java softens this. Since Java 8, when a single bucket gets too crowded&lt;br&gt;
(past a threshold, once the table is large enough), the &lt;code&gt;HashMap&lt;/code&gt; converts that&lt;br&gt;
bucket's internal linked list into a balanced red-black tree, making lookups&lt;br&gt;
within it O(log n) instead of O(n). Better, but still a serious regression from&lt;br&gt;
O(1), and it only helps if the keys implement &lt;code&gt;Comparable&lt;/code&gt; so the tree has&lt;br&gt;
something to sort by. The lesson: a correct &lt;code&gt;hashCode&lt;/code&gt; is not enough, it also&lt;br&gt;
needs to spread values out. A good &lt;code&gt;hashCode&lt;/code&gt; distributes; a bad one clumps.&lt;/p&gt;
&lt;h3&gt;
  
  
  4.4 The symmetry trap
&lt;/h3&gt;

&lt;p&gt;This one catches even experienced developers, and it is a favorite interview&lt;br&gt;
question. It happens when you extend a value class and add a field to compare.&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;class&lt;/span&gt; &lt;span class="nc"&gt;Point&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;Point&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;x&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;y&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&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;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;;&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;boolean&lt;/span&gt; &lt;span class="nf"&gt;equals&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;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="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;Point&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;x&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;y&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;;&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;int&lt;/span&gt; &lt;span class="nf"&gt;hashCode&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;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&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;class&lt;/span&gt; &lt;span class="nc"&gt;ColorPoint&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Point&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;ColorPoint&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;x&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;y&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;color&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&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;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;;&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;boolean&lt;/span&gt; &lt;span class="nf"&gt;equals&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;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="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;ColorPoint&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
                &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&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;color&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="nc"&gt;Point&lt;/span&gt; &lt;span class="n"&gt;p&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;Point&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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="nc"&gt;ColorPoint&lt;/span&gt; &lt;span class="n"&gt;cp&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;ColorPoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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="s"&gt;"red"&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;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cp&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// true   (Point.equals only checks x and y)&lt;/span&gt;
&lt;span class="n"&gt;cp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&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="c1"&gt;// false  (ColorPoint.equals also demands a matching color)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;equals&lt;/code&gt; is now asymmetric: &lt;code&gt;p.equals(cp)&lt;/code&gt; and &lt;code&gt;cp.equals(p)&lt;/code&gt; disagree. That&lt;br&gt;
breaks the symmetry rule from Section 3, and it produces bugs that depend on&lt;br&gt;
argument order, which are miserable to track down. Worse, a common "fix" (make&lt;br&gt;
&lt;code&gt;ColorPoint&lt;/code&gt; ignore color when compared against a plain &lt;code&gt;Point&lt;/code&gt;) trades the&lt;br&gt;
symmetry break for a transitivity break, so you cannot patch your way out.&lt;/p&gt;

&lt;p&gt;The real resolution is a design rule: there is no way to extend an instantiable&lt;br&gt;
value class with a new value component and preserve the &lt;code&gt;equals&lt;/code&gt; contract. Two&lt;br&gt;
clean options exist. Use exact-class matching instead of &lt;code&gt;instanceof&lt;/code&gt;, so a&lt;br&gt;
&lt;code&gt;Point&lt;/code&gt; and a &lt;code&gt;ColorPoint&lt;/code&gt; are simply never equal:&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;@Override&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;equals&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;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;if&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="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;getClass&lt;/span&gt;&lt;span class="o"&gt;()&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;getClass&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="nc"&gt;Point&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Point&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="k"&gt;return&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;x&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;y&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;y&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 for most cases, favor composition over inheritance: give &lt;code&gt;ColorPoint&lt;/code&gt;&lt;br&gt;
a &lt;code&gt;Point&lt;/code&gt; field and a method to expose it, rather than extending &lt;code&gt;Point&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  4.5 The TreeSet twist: compareTo must agree with equals
&lt;/h3&gt;

&lt;p&gt;Everything so far has been about hash-based collections. Tree-based collections&lt;br&gt;
play by a completely different rulebook, and knowing that difference is what&lt;br&gt;
separates someone who memorized the contract from someone who understands it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TreeMap&lt;/code&gt; and &lt;code&gt;TreeSet&lt;/code&gt; do not call &lt;code&gt;hashCode()&lt;/code&gt; at all. Not once. They are not&lt;br&gt;
built on buckets. They keep elements in sorted order using either &lt;code&gt;compareTo()&lt;/code&gt;&lt;br&gt;
(if the type implements &lt;code&gt;Comparable&lt;/code&gt;) or a &lt;code&gt;Comparator&lt;/code&gt; you supply. So a&lt;br&gt;
catastrophically broken &lt;code&gt;hashCode&lt;/code&gt;, even &lt;code&gt;return 42&lt;/code&gt;, causes a &lt;code&gt;TreeSet&lt;/code&gt; zero&lt;br&gt;
problems.&lt;/p&gt;

&lt;p&gt;But a different contract governs them: &lt;code&gt;compareTo&lt;/code&gt; should be consistent with&lt;br&gt;
&lt;code&gt;equals&lt;/code&gt;, meaning &lt;code&gt;a.compareTo(b) == 0&lt;/code&gt; in exactly the same situations where&lt;br&gt;
&lt;code&gt;a.equals(b)&lt;/code&gt; is true. Break that alignment and the tree quietly redefines&lt;br&gt;
equality on your behalf, because to a tree, two elements are "the same" precisely&lt;br&gt;
when &lt;code&gt;compareTo&lt;/code&gt; returns zero, no matter what &lt;code&gt;equals&lt;/code&gt; says.&lt;/p&gt;

&lt;p&gt;The canonical demonstration is &lt;code&gt;BigDecimal&lt;/code&gt;, because it is real, it is in the&lt;br&gt;
standard library, and it disagrees with itself on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;a&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;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.0"&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;b&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;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"1.00"&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="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// false   equals also compares scale (1 vs 2 decimals)&lt;/span&gt;
&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compareTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// 0       compareTo compares numeric value only&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;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hash&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;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;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="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&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;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tree&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;TreeSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;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="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;hash&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;// 2   HashSet uses equals, which says they differ&lt;/span&gt;
&lt;span class="n"&gt;tree&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;// 1   TreeSet uses compareTo, which says they are identical&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical input, two &lt;code&gt;Set&lt;/code&gt; implementations, two different answers. If code ever&lt;br&gt;
fills one kind of set from a list of &lt;code&gt;BigDecimal&lt;/code&gt; and later switches to the&lt;br&gt;
other, the element count silently changes and nothing throws.&lt;/p&gt;

&lt;p&gt;The same trap with your own class, to show it is not special to &lt;code&gt;BigDecimal&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Person&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Comparable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Person&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="n"&gt;name&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;id&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;age&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// equals and hashCode built from name + id (correct and consistent)&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;int&lt;/span&gt; &lt;span class="nf"&gt;compareTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Person&lt;/span&gt; &lt;span class="n"&gt;other&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;Integer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compare&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;age&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// BUG: ordering ignores identity&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;TreeSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Person&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;set&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;TreeSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;set&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Person&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Asha"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;set&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Person&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Ravi"&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="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// different person, same age&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;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set&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;// 1  Ravi rejected as a "duplicate"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two clearly different people who happen to share an age. The &lt;code&gt;TreeSet&lt;/code&gt; compares&lt;br&gt;
them with &lt;code&gt;compareTo&lt;/code&gt;, gets zero, decides they are the same element, and refuses&lt;br&gt;
to add the second. No error, no warning, just a missing record.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. How to write them correctly
&lt;/h2&gt;

&lt;p&gt;Now that you have seen every way it breaks, the rules for doing it right are just&lt;br&gt;
the failures turned around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Override &lt;code&gt;hashCode&lt;/code&gt; and &lt;code&gt;equals&lt;/code&gt; together, from the same fields.&lt;/strong&gt; If &lt;code&gt;equals&lt;/code&gt;&lt;br&gt;
compares email and name, then &lt;code&gt;hashCode&lt;/code&gt; must be derived from email and name, so&lt;br&gt;
two objects equal in those fields are guaranteed to hash the same. The modern&lt;br&gt;
helpers make this a two-liner:&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;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&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;equals&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;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;if&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="n"&gt;o&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;true&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;o&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;u&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&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="n"&gt;u&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="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;int&lt;/span&gt; &lt;span class="nf"&gt;hashCode&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;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// same fields as equals&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;Objects.equals&lt;/code&gt; handles nulls on both sides, and the &lt;code&gt;instanceof&lt;/code&gt; check makes&lt;br&gt;
&lt;code&gt;equals(null)&lt;/code&gt; return false for free, since &lt;code&gt;null instanceof User&lt;/code&gt; is false.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer immutable fields for anything used in &lt;code&gt;hashCode&lt;/code&gt;,&lt;/strong&gt; so you never hit the&lt;br&gt;
stranded-key leak from Section 4.2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your type is &lt;code&gt;Comparable&lt;/code&gt;, keep &lt;code&gt;compareTo&lt;/code&gt; consistent with &lt;code&gt;equals&lt;/code&gt;,&lt;/strong&gt; so&lt;br&gt;
you never hit the &lt;code&gt;BigDecimal&lt;/code&gt; or &lt;code&gt;Person&lt;/code&gt; surprises from Section 4.5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not add a value component by extending an instantiable class,&lt;/strong&gt; per the&lt;br&gt;
symmetry trap in Section 4.4. Use composition, or exact-class equality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Easiest correct path: use a record.&lt;/strong&gt; A Java record generates a correct&lt;br&gt;
&lt;code&gt;equals&lt;/code&gt;, &lt;code&gt;hashCode&lt;/code&gt;, and &lt;code&gt;toString&lt;/code&gt; from its components automatically, and they&lt;br&gt;
are always consistent with each other. For a plain immutable value holder, this&lt;br&gt;
removes the whole class of bugs above:&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;record&lt;/span&gt; &lt;span class="nf"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// equals and hashCode are generated, correct, and mutually consistent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you cannot use a record, let your IDE generate both methods together, or use&lt;br&gt;
a library like Lombok's &lt;code&gt;@EqualsAndHashCode&lt;/code&gt;. The danger is almost always&lt;br&gt;
hand-writing one method and forgetting or mismatching the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Quick reference
&lt;/h2&gt;

&lt;p&gt;A lookup table for after you have read the reasoning above. The value of each row&lt;br&gt;
is the reasoning in the linked section, not the one-liner.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;map.get(equalKey)&lt;/code&gt; returns null (4.1)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;equals&lt;/code&gt; overridden, &lt;code&gt;hashCode&lt;/code&gt; not&lt;/td&gt;
&lt;td&gt;Override both from the same fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;HashSet&lt;/code&gt; stores equal duplicates (4.1)&lt;/td&gt;
&lt;td&gt;same as above&lt;/td&gt;
&lt;td&gt;Override both from the same fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry cannot be found or removed after a change (4.2)&lt;/td&gt;
&lt;td&gt;Key mutated after insertion&lt;/td&gt;
&lt;td&gt;Make keys immutable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correct but slow lookups (4.3)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hashCode&lt;/code&gt; returns few distinct values&lt;/td&gt;
&lt;td&gt;Distribute the hash across fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order-dependent equality bugs (4.4)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;equals&lt;/code&gt; is asymmetric via inheritance&lt;/td&gt;
&lt;td&gt;Composition, or &lt;code&gt;getClass()&lt;/code&gt; equality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;HashSet&lt;/code&gt; and &lt;code&gt;TreeSet&lt;/code&gt; disagree on size (4.5)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;compareTo&lt;/code&gt; inconsistent with &lt;code&gt;equals&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Make &lt;code&gt;compareTo == 0&lt;/code&gt; match &lt;code&gt;equals == true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;TreeSet&lt;/code&gt; silently drops distinct elements (4.5)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;compareTo&lt;/code&gt; returns 0 for unequal objects&lt;/td&gt;
&lt;td&gt;Same as above&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Interview-ready one-liners, each of which the sections above let you defend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;HashMap&lt;/code&gt; finds an entry in two steps: &lt;code&gt;hashCode&lt;/code&gt; selects the bucket, then
&lt;code&gt;equals&lt;/code&gt; confirms inside it. Equal objects must hash equally, or the lookup
searches the wrong bucket and &lt;code&gt;equals&lt;/code&gt; never runs.&lt;/li&gt;
&lt;li&gt;A bad &lt;code&gt;hashCode&lt;/code&gt; that breaks the contract makes lookups wrong; a bad
&lt;code&gt;hashCode&lt;/code&gt; that merely collides a lot makes them slow. Only the first is a
correctness bug.&lt;/li&gt;
&lt;li&gt;Tree collections never touch &lt;code&gt;hashCode&lt;/code&gt;; they order by &lt;code&gt;compareTo&lt;/code&gt;, and there
the rule is that &lt;code&gt;compareTo&lt;/code&gt; must agree with &lt;code&gt;equals&lt;/code&gt;. &lt;code&gt;BigDecimal&lt;/code&gt; breaks
that on purpose, so &lt;code&gt;"1.0"&lt;/code&gt; and &lt;code&gt;"1.00"&lt;/code&gt; give a &lt;code&gt;HashSet&lt;/code&gt; of size two but a
&lt;code&gt;TreeSet&lt;/code&gt; of size one.&lt;/li&gt;
&lt;li&gt;The cleanest way to get all of this right is a record, which generates
consistent &lt;code&gt;equals&lt;/code&gt; and &lt;code&gt;hashCode&lt;/code&gt; for you.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>computerscience</category>
      <category>java</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I built a resume tailor that refuses to lie about you</title>
      <dc:creator>srinivas reddy gouru</dc:creator>
      <pubDate>Thu, 13 Aug 2026 20:32:59 +0000</pubDate>
      <link>https://dev.to/srinivas_gouru_d26dc31f21/i-built-a-resume-tailor-that-refuses-to-lie-about-you-h6g</link>
      <guid>https://dev.to/srinivas_gouru_d26dc31f21/i-built-a-resume-tailor-that-refuses-to-lie-about-you-h6g</guid>
      <description>&lt;p&gt;Upload a resume and a job description, get an explainable ATS score, an honest rewrite with a per-line change log, and a coach that will never invent a number for you. The most important thing is, it works with your subscriptions. Here is the full walkthrough.&lt;/p&gt;

&lt;p&gt;Every resume tool I tried had the same problem. I would paste in a job description, hit tailor, and get back a resume that was genuinely better written and quietly not true. A title nudged upward. A skill I had never used, sitting in a bullet as if it had always been there a number, always a good round one, that nobody had ever measured.&lt;/p&gt;

&lt;p&gt;The tools were not broken. They were doing what I asked: make my resume to match the job. Truth was not part of the objective.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/srinivas-reddy-gouru/scrivio" rel="noopener noreferrer"&gt;Scrivio's resume studio&lt;/a&gt; around the opposite premise: the rewrite is only worth having if every fact in it is yours. This is a walkthrough of what actually happens when you run a resume through it, in the order you would experience it.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Step 1: Upload the resume and the job description
&lt;/h2&gt;

&lt;p&gt;You can paste your resume as text, upload a PDF or DOCX, or import an existing &lt;code&gt;resume.json&lt;/code&gt;. For the job description, you can paste it, give a URL, or pick a job target you already saved in the job interview studio.&lt;/p&gt;

&lt;p&gt;The first thing that happens is not a model call. Your resume is extracted once into &lt;a href="https://jsonresume.org" rel="noopener noreferrer"&gt;JSON Resume&lt;/a&gt;, an open standard with defined fields for work, education, skills, and projects. That extraction pass has exactly one job: faithful text-to-structure mapping, with no improvement and no invention.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds. Once the resume is a structure rather than a blob, every step can address a specific field. The change log can say &lt;code&gt;work[0].highlights[2]&lt;/code&gt;. The interface can draw a mark on the exact line that changed. The honesty rules can compare fields instead of scanning the prose. And because JSON Resume is a public standard rather than my format, the file round-trips into other tools in that ecosystem, so the structure is a door out rather than a lock-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The ATS report comes back before the AI does
&lt;/h2&gt;

&lt;p&gt;Here is the part I am most pleased with. The scoring is pure Python, so it returns in the same response as the upload, in under a second, while the model work continues in the background. You are reading a real report immediately.&lt;/p&gt;

&lt;p&gt;The score is a weighted checklist, and every row tells you what it looked at and why it passed or failed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contact info up top&lt;/strong&gt;, because ATS field parsers read email and phone from the first several lines and will miss them in a header graphic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standard section headers&lt;/strong&gt;, at least three recognised ones, because that is how an ATS segments your document&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quantified achievements&lt;/strong&gt;, at least a third of your bullets carrying a real number&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent date format&lt;/strong&gt;, because mixing "Jan 2024" and "01/2024" confuses date parsing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-column, parse-safe layout&lt;/strong&gt;, detected from column and table artefacts in the extracted text&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No stuffing or filler&lt;/strong&gt;, because repeating one keyword eight times reads as a fraud signal to modern systems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verb-first language&lt;/strong&gt;, flagging "responsible for", "tasked with", "leveraged", and the rest of the duty-speak family&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With a job description attached, a deterministic keyword-coverage percentage joins in, and the final number becomes 70% checks and 30% coverage.&lt;/p&gt;

&lt;p&gt;There is no model anywhere in that score, which is the point. You can argue with it. It gives the same answer on Tuesday that it gave on Monday. And when it says you failed a check, it tells you what it counted: "6/19 bullets are quantified (32%)" rather than a mysterious 61 out of 100.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Tailor it
&lt;/h2&gt;

&lt;p&gt;Now the model gets involved and works on the structure rather than on text with some restrictive rules.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;may&lt;/strong&gt; rephrase bullets and reorder them so that the most job-relevant work leads, condense material irrelevant to this role, rewrite your summary to mirror the role's actual title, and reorder your skills so matching ones come first.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;may not&lt;/strong&gt; invent employers, titles, employment dates, degrees, institutions, certificates, metrics, or skills, but the Job history is never deleted, only reordered and condensed.&lt;/p&gt;

&lt;p&gt;Adding a keyword from the job description is allowed in exactly one situation: when your resume already shows a real basis for it. If the posting says Kubernetes and you have a bullet describing deploying containerised services on k8s, it can name Kubernetes there. If there is no honest basis, it does not add the keyword. It tells you instead, in a warning that begins "Cannot honestly claim."&lt;/p&gt;

&lt;p&gt;Then, after the model is done, deterministic code checks its work. Employers must appear on your original, or the entry is removed. Titles and employment dates must match byte for byte, or they are reverted. Institutions and certificates must be ones you actually have. Every correction is surfaced to you as a plain-English warning, never fixed silently:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Reverted job title at Acme to "Software Engineer II"; titles cannot be changed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The prompt is the request. This is the contract. Only one of them is something I can write a test for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Read the change log, not just the result
&lt;/h2&gt;

&lt;p&gt;You get back a rewrite plus a per-field record of every edit: what changed, where, and why it serves this specific job. In the classic interface, these are a list. In the React interface at &lt;code&gt;/desk&lt;/code&gt;, which renders your resume as paper with findings drawn on it, they are marks on the lines themselves, linked two ways to a review rail.&lt;/p&gt;

&lt;p&gt;The colors carry meaning. Red-pen underlines are ATS problems. Amber chips are honesty issues, things that need your decision. Teal bars are improvements that were made.&lt;/p&gt;

&lt;p&gt;The change log is not decoration. A rewrite you cannot audit is a rewrite you have to trust blindly, and blind trust is exactly what got fabricated bullets onto resumes in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Fill in your own numbers
&lt;/h2&gt;

&lt;p&gt;When a bullet would clearly be stronger with a figure your resume does not contain, the tailor does not estimate one. It writes the literal token &lt;code&gt;[METRIC]&lt;/code&gt;, capped at four per resume so the result does not read as unfinished, and logs each one.&lt;/p&gt;

&lt;p&gt;You get "Cut deploy time by [METRIC]%" and a note telling you where the real number lives, your CI history in that case. Then you fill them in inline, right where they will print.&lt;/p&gt;

&lt;p&gt;The same rule protects every later edit. A numeral in edited text has to already exist somewhere in your resume or in something you typed yourself. Anything else reverts, with a warning naming the invented figure. Which also means that if you tell the tool "deploy time went from 20 minutes to 3," those numbers become legal everywhere, because they came from you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Ask the coach
&lt;/h2&gt;

&lt;p&gt;There is a chat panel beside your resume that can see both your resume and the job description, and it is deliberately read-only. It cannot change anything.&lt;/p&gt;

&lt;p&gt;Ask it what metric belongs in a weak bullet and it will quote the bullet, explain in one sentence what a recruiter thinks when they read it, name the kind of metric that would land and the specific place to find it (CloudWatch, pipeline history, billing console, org chart), and show you a template with a slot where your figure goes. It covers at most three bullets per answer so the advice stays deep rather than broad.&lt;/p&gt;

&lt;p&gt;What it will never do is give you a number. It does not know your figures, and an invented one is a lie you get asked about in an interview.&lt;/p&gt;

&lt;p&gt;When you want its advice applied, there is a "Make this edit" button. Type an instruction, or leave the box empty and it applies what the coach recommended in the conversation. Every edit goes through the same honesty guard, and the previous version lands on an undo stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Export
&lt;/h2&gt;

&lt;p&gt;Download either version, original or tailored, as PDF (what application portals want), DOCX, Markdown, or JSON Resume. All four are renderers over the same structure, so they cannot drift from each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing it checks
&lt;/h2&gt;

&lt;p&gt;My favourite rule is one no human reviewer would think to run. Java 17 became generally available in September 2021. If your resume claims Java 17 on a role that ended in 2020, that is not a style problem; it is impossible, and an interviewer who knows the ecosystem will catch it in one question.&lt;/p&gt;

&lt;p&gt;The tool flags it, quotes your bullet, names the GA date, and offers the honest fix: drop the version, or scope the claim to when the upgrade actually happened. It does not correct the claim itself. Your history is yours, and only you know what really happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  It runs on the subscription you already pay for
&lt;/h2&gt;

&lt;p&gt;The whole studio can route its model calls through a local AI CLI instead of a metered API. If you have Claude Pro, Claude Code is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @anthropic-ai/claude-code &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; claude login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick "Local CLI" in Settings, and the marginal cost is zero. Codex, Gemini, Qwen, and Ollama are configured the same way: one setting, no code changes. Ollama provides fully local models with no account associated with them.&lt;/p&gt;

&lt;p&gt;Being straight about the trade-off: CLI calls spawn a process each time, so they are slower than the API and bound by your plan's own limits. For resume work, which is a handful of calls, you will not notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/srinivas-reddy-gouru/scrivio.git
&lt;span class="nb"&gt;cd &lt;/span&gt;scrivio
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
python &lt;span class="nt"&gt;-m&lt;/span&gt; uvicorn api.server:app &lt;span class="nt"&gt;--port&lt;/span&gt; 8899
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8899&lt;/code&gt;, go to the resume studio, and paste a resume and a job posting.&lt;/p&gt;

&lt;p&gt;It is MIT licensed and runs entirely on your machine. Your resume never leaves it except in the model calls you configure, and with a local CLI, it does not leave at all.&lt;/p&gt;

&lt;p&gt;The thing I would want you to take away, whether or not you use it: a resume tool that will not tell you is not on your side. If it happily promotes your title, it will happily do that on the version you send to a company that checks.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>career</category>
      <category>opensource</category>
      <category>python</category>
    </item>
    <item>
      <title>I Built Scrivio: An AI Pipeline That Researches, Fact-Checks, and Polishes Technical Articles</title>
      <dc:creator>srinivas reddy gouru</dc:creator>
      <pubDate>Sun, 14 Jun 2026 04:50:03 +0000</pubDate>
      <link>https://dev.to/srinivas_gouru_d26dc31f21/i-built-scrivio-an-ai-pipeline-that-researches-fact-checks-and-polishes-technical-articles-ihk</link>
      <guid>https://dev.to/srinivas_gouru_d26dc31f21/i-built-scrivio-an-ai-pipeline-that-researches-fact-checks-and-polishes-technical-articles-ihk</guid>
      <description>&lt;p&gt;&lt;em&gt;Why I stopped asking a chatbot to “write me an article” and instead built a multi-stage pipeline.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/srinivas-reddy-gouru/scrivio" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/srinivas-reddy-gouru/scrivio&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;I like writing about the things I build and learn. The hard part was never the writing itself; it was everything that comes &lt;em&gt;before&lt;/em&gt; the writing: digging up sources, checking that what I remembered about a framework was still true, drawing the diagram, and then rewriting the whole thing twice because the first draft sounded like a product brochure.&lt;/p&gt;

&lt;p&gt;At some point, I did the obvious thing and asked an AI to write a draft for me. And it did, in about four seconds. But the draft had a familiar problem. It was confident, fluent, and quietly wrong in a few places. It cited nothing. It pulled “facts” from training data that may or may not have matched the current version of the library I was writing about. The diagrams didn’t exist. And it had that unmistakable AI texture where every paragraph is the same temperature.&lt;/p&gt;

&lt;p&gt;So I kept the idea but threw away the approach. Instead of one model doing one pass, I wanted a pipeline: research first, draft against real evidence, verify every claim, then edit and polish as an actual editorial team would. That turned into  &lt;strong&gt;Scrivio&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the story of what it does, how it works, and the honest answer to the question everyone asks first: &lt;em&gt;why not just use ChatGPT or Claude directly?&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What Scrivio actually does
&lt;/h3&gt;

&lt;p&gt;You type a topic. That’s the whole input. Something like How does Kafka handle backpressure? or Spring Security 6 filter chain. From there, Scrivio runs the full chain on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Researches&lt;/strong&gt; the live web for real, recent sources instead of leaning on training data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plans&lt;/strong&gt; a section-by-section structure tuned to your audience and depth level&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draft&lt;/strong&gt; each section with inline citations tied to the evidence it actually fetched&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generates Mermaid diagrams&lt;/strong&gt; for architecture, flows, and sequence interactions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verifies&lt;/strong&gt; every factual claim against the source it came from&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edits&lt;/strong&gt; for thesis alignment, voice, and structure (it’ll add a comparison table when the content calls for one)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polishes&lt;/strong&gt; the voice so it reads like a colleague explaining something, not a generated document&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compiles&lt;/strong&gt; the final article with numbered references resolved at the end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The output is a clean Markdown file, ready to paste into a blog, Notion, or docs site that renders Markdown and Mermaid.&lt;/p&gt;

&lt;p&gt;One thing to get straight up front: Scrivio isn’t locked to a single provider. It auto-selects based on the API key you give it. Drop in an Anthropic key, and Claude runs the pipeline. Drop in an OpenAI key and the whole pipeline runs on GPT-4o and GPT-4o-mini instead. Set both, and it defaults to Anthropic unless you tell it otherwise with a LLM_PROVIDER setting. There's no code change involved — a single adapter presents the OpenAI client to every worker as if it were the Anthropic one, so the workers don't even know which provider they're talking to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which provider runs the pipeline&lt;/strong&gt; depends on the keys you set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic only&lt;/strong&gt;  — Pipeline runs on Claude (Sonnet + Haiku) · Verifier: GPT-4o-mini &lt;em&gt;(needs the OpenAI key)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI only&lt;/strong&gt;  — Pipeline runs on GPT-4o + GPT-4o-mini · Verifier: GPT-4o-mini&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both&lt;/strong&gt;  — Anthropic by default, or OpenAI via LLM_PROVIDER=openai · Verifier: GPT-4o-mini&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three quality presets let you trade speed for polish, depending on whether you’re prototyping or shipping. The model names below are the Anthropic defaults; in OpenAI mode, each one maps to its nearest GPT equivalent (Sonnet → GPT-4o, Haiku → GPT-4o-mini), so the preset slider still does its job either way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quality presets&lt;/strong&gt;  — model names are the Anthropic defaults; in OpenAI mode, Sonnet maps to GPT-4o and Haiku to GPT-4o-mini.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fast&lt;/strong&gt;  — Routing: Haiku / GPT-4o-mini · Writing: Sonnet / GPT-4o · &lt;em&gt;Best for: quick drafts and prototyping&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Balanced&lt;/strong&gt; (default) — Routing: Haiku / GPT-4o-mini · Writing: Sonnet / GPT-4o · &lt;em&gt;Best for: the best cost/quality ratio&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best&lt;/strong&gt;  — Routing: Sonnet / GPT-4o · Writing: Sonnet / GPT-4o · &lt;em&gt;Best for: production-quality articles&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The slider in the UI doesn’t just change one model. Every stage in the pipeline asks for a central config which model it should use for the current preset, so the whole writing chain moves together when you flip it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Under the hood: it’s a pipeline, not a prompt
&lt;/h3&gt;

&lt;p&gt;The core mental model is simple. Scrivio is a FastAPI server that takes a request, runs the job in the background, and streams progress to the browser stage by stage so you can watch it think. The UI is a single static page.&lt;/p&gt;

&lt;p&gt;The interesting part is what sits behind that server. Rather than one big “write an article” call, the work is split across a chain of specialised workers, each with one job and its own prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;topic
  → brief (thesis, angle, hook)
  → relevance check (does the brief match what you asked?)
  → web search + extraction (fetch real pages, chunk them, score relevance)
  → planning (section-by-section outline mapped to evidence)
  → gap-fill search (find more sources for thin sections)
  → drafting (every section in parallel, each claim cited)
  → diagrams (Mermaid spec → rendered SVG)
  → verification (claim-by-claim fact check)
  → editor review + targeted revision
  → critic (final quality gate)
  → humanizer (voice polish, AI-tell scrub)
  → compile (resolve citations into numbered references)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few of these stages are the ones I’m quietly proud of, because they’re the parts a single chatbot call doesn’t do.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;relevance check&lt;/strong&gt; runs a cheap Haiku pass right after the brief is written, purely to catch drift. If you ask, “What is X?” and the brief quietly decides to write a dramatic war story instead, it gets caught &lt;em&gt;before&lt;/em&gt; the expensive stages spend tokens on the wrong article.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;editor&lt;/strong&gt; runs two passes. First, it reviews the assembled draft and flags up to a few sections that need rewriting, plus structural hints (for example, “these three things are being compared across two dimensions, turn this into a table”). Then it re-drafts only the flagged sections, so a single weak paragraph doesn’t trigger a full regeneration.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;critic&lt;/strong&gt; is the final gate. It checks title patterns, opening framing, citation completeness, diagram accuracy, internal consistency, and code-block fidelity, and grades issues as blocking, moderate, or minor. The article only ships if nothing is blocking the remains.&lt;/p&gt;

&lt;h3&gt;
  
  
  “But Claude or ChatGPT can already write an article…”
&lt;/h3&gt;

&lt;p&gt;This is a fair question, and I want to answer it honestly rather than pretend Scrivio invented writing.&lt;/p&gt;

&lt;p&gt;Yes. If you open Claude or ChatGPT and ask for a technical article, you’ll get something readable in seconds. For a casual post, that’s often enough, and Scrivio is overkill. I’m not going to tell you to spin up a pipeline to write a 300-word “5 tips” listicle.&lt;/p&gt;

&lt;p&gt;The gap shows up the moment you care about a technical article being &lt;em&gt;correct, sourced, and not obviously machine-written&lt;/em&gt;. A single chat call gives you one pass from a single model working off training data. Scrivio is built around the things that one pass skips:&lt;/p&gt;

&lt;p&gt;Here’s that comparison table reflowed for Medium. Since it has three columns, the clearest way is one bolded item per line with the two sides underneath:&lt;/p&gt;

&lt;p&gt;A single Claude or ChatGPT call gives you a fast draft. Here’s where Scrivio differs, point by point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Up-to-date facts&lt;/strong&gt;  — &lt;em&gt;Single call:&lt;/em&gt; from training data, no live sources. &lt;em&gt;Scrivio:&lt;/em&gt; live web search, fetched per run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Citations&lt;/strong&gt;  — &lt;em&gt;Single call:&lt;/em&gt; rarely, and often invented. &lt;em&gt;Scrivio:&lt;/em&gt; every claim tied to a fetched source, numbered at the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fact-checking&lt;/strong&gt;  — &lt;em&gt;Single call:&lt;/em&gt; the model checks itself, if at all. &lt;em&gt;Scrivio:&lt;/em&gt; a separate verifier model checks every claim (a different family when you run in Claude mode).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagrams&lt;/strong&gt;  — &lt;em&gt;Single call:&lt;/em&gt; described in text, not drawn. &lt;em&gt;Scrivio:&lt;/em&gt; real rendered Mermaid SVGs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editing&lt;/strong&gt;  — &lt;em&gt;Single call:&lt;/em&gt; one pass, one voice. &lt;em&gt;Scrivio:&lt;/em&gt; separate editor, critic, and humaniser passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproducibility&lt;/strong&gt;  — &lt;em&gt;Single call:&lt;/em&gt; new chat means starting over. &lt;em&gt;Scrivio:&lt;/em&gt; same topic, same pipeline, repeatable settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The verification step is the one I’d point to first. Whoever does the writing, a separate verifier model goes through the draft claim by claim and downgrades anything the cited source doesn’t actually support. A model grading its own homework is a known weak spot, and this catches a class of confident-but-wrong statements that self-review tends to wave through. It’s strongest in the default Claude setup, where the writer is Claude and the verifier is GPT-4o-mini — a genuinely different model family doing the checking. If you run the whole thing on OpenAI, the verifier is still an independent, smaller-model pass, just from the same family, so treat it as a second opinion rather than a fully independent one.&lt;/p&gt;

&lt;p&gt;So the honest framing is this: a chatbot is a brilliant &lt;em&gt;writer&lt;/em&gt;. Scrivio is closer to a small &lt;em&gt;newsroom&lt;/em&gt; — researcher, drafter, fact-checker, editor, and copy-editor — that happens to be made of models. Where it shines is exactly where a fast single draft falls: sourced, verifiable, diagram-supported technical writing you’d actually put your name on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using it
&lt;/h3&gt;

&lt;p&gt;There are three ways in, depending on how much setup you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web UI (the nice one).&lt;/strong&gt; Start the server, open localhost:8899, Type a topic, pick depth and audience, choose a preset, and hit generate. The progress panel streams each stage live, which honestly became my favourite part to watch. You can toggle live web search on or off, and flip diagrams on or off, right there in the form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command line.&lt;/strong&gt; If you’d rather script it:&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;# Fastest: no web search, no diagrams&lt;/span&gt;
python main.py &lt;span class="nt"&gt;--topic&lt;/span&gt; &lt;span class="s2"&gt;"pytest best practices"&lt;/span&gt; &lt;span class="nt"&gt;--level&lt;/span&gt; basic &lt;span class="nt"&gt;--no-web&lt;/span&gt; &lt;span class="nt"&gt;--no-diagrams&lt;/span&gt;

&lt;span class="c"&gt;# Full run with search and diagrams&lt;/span&gt;
python main.py &lt;span class="nt"&gt;--topic&lt;/span&gt; &lt;span class="s2"&gt;"How does Kafka handle backpressure?"&lt;/span&gt; &lt;span class="nt"&gt;--level&lt;/span&gt; intermediate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output gets written to a timestamped folder with the finished Markdown and a meta.json containing the request params and verification reports, in case you want to see &lt;em&gt;why&lt;/em&gt; a claim was kept or dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code skill (no setup at all).&lt;/strong&gt; There’s a standalone generate-article.skill in the repo. Drag it into a Claude Code chat (or install it from Settings → Skills), ask it to "write an article about how Kafka handles backpressure," and it runs the workflow with no server, no clone, and no API keys of your own. It's a single self-contained SKILL.md that walks Claude Code through the same eight stages — brief, research, plan, draft, editorial review, critique, and file output — using Claude Code's built-in web search and file tools. This is the lowest-friction way to try the idea before touching the full project.&lt;/p&gt;

&lt;p&gt;It is worth being precise about one difference: because the skill runs entirely inside Claude Code on a single model, it skips the separate cross-model verification step that the full server pipeline does. Its review and critique passes are Claude checking its own draft, not an independent verifier from another family. So the skill is the fastest way to feel out the workflow, and the full pipeline is the one to reach for when you want that extra layer of fact-checking.&lt;/p&gt;

&lt;h3&gt;
  
  
  What’s still rough (and what I’m working on)
&lt;/h3&gt;

&lt;p&gt;I’d rather be upfront about the edges than oversell this. It works well for what it does, but it’s early, and there’s a list I’m actively chipping away at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Job storage is in-memory right now.&lt;/strong&gt; Jobs live in the server process, so a restart loses in-flight work. Moving to a persistent store (and a proper queue) is near the top of the list, especially before anyone tries to host this rather than run it locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key setup could still be simpler.&lt;/strong&gt; You can already run the whole pipeline on a single provider key — just Anthropic, or just OpenAI — and web search is optional on top of that. What I still want to smooth out is the verifier: right now, it always reaches for OpenAI, so the cleanest “one key only” experience is OpenAI mode. Making the verifier fully provider-agnostic (so an Anthropic-only setup needs nothing else) is on the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There’s no auth on the server.&lt;/strong&gt; That’s fine for localhost, but a hosted deployment needs it, so it's a prerequisite for the "run it as a small service" direction I'd like to take.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Output is Markdown only.&lt;/strong&gt; The pipeline already reasons about basic/intermediate/advanced depth levels internally, but the surfaced output is a single file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost visibility.&lt;/strong&gt; A full “Best” run touches on many models. I want the UI to show a token and cost estimate up front so a run never surprises you.&lt;/p&gt;

&lt;p&gt;None of these is blockers for the core use case, but they’re the difference between “a tool I built for myself” and “a tool other people can comfortably depend on,” and that’s the direction I’m steering it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it
&lt;/h3&gt;

&lt;p&gt;If sourced, fact-checked technical writing is a problem you also have, the repo is here, and it’s MIT-licensed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/srinivas-reddy-gouru/scrivio" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/srinivas-reddy-gouru/scrivio&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are five real example articles in the examples/ folder if you want to judge the output before installing anything. And if you try it, I'd genuinely like to hear where it breaks for you — what topics it nails, where the verification is too strict or too loose, and which export format you'd reach for first. That feedback determines what I build next.&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

&lt;h3&gt;
  
  
  A few quick FAQs
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Does this replace writing the article yourself?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, and it’s not trying to. It removes the blank page, the source-hunting, and the first three drafts. The judgment, the opinions, and the final read-through are still yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need both Anthropic and OpenAI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Scrivio runs on whichever you give it — an Anthropic key runs the pipeline on Claude, an OpenAI key runs it on GPT-4o. The one nuance is verification, which currently uses GPT-4o-mini, so an OpenAI key gives you the most self-contained single-key setup today. In the default Claude setup, the writer and the verifier come from different model families on purpose, since a model checking its own work is exactly what I wanted to avoid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it free to run?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The code is MIT and free. The API calls are not — you bring your own keys, and a full “Best” run with web search costs real tokens. The “Fast” and “Balanced” presets exist precisely to keep that reasonable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What’s the minimum to get started?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One LLM key. Just Anthropic or just OpenAI is enough to generate articles; web search is optional and only needs its own key if you want live sources instead of model knowledge. So the smallest setup is a single key, no search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to clone the repo to try it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The generate-article.skill file installs straight into Claude Code (drag it in, or Settings → Skills), and from there you just ask it to write an article. It uses Claude Code's own tools, so there's nothing else to set up. The full repo is for when you want the server UI, the cross-model verification, and full control over the pipeline.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How Kafka Handles Backpressure: Producer Buffers, Broker Quotas, and Consumer Flow Control</title>
      <dc:creator>srinivas reddy gouru</dc:creator>
      <pubDate>Mon, 08 Jun 2026 02:33:05 +0000</pubDate>
      <link>https://dev.to/srinivas_gouru_d26dc31f21/how-kafka-handles-backpressure-producer-buffers-broker-quotas-and-consumer-flow-control-39oh</link>
      <guid>https://dev.to/srinivas_gouru_d26dc31f21/how-kafka-handles-backpressure-producer-buffers-broker-quotas-and-consumer-flow-control-39oh</guid>
      <description>&lt;p&gt;Backpressure is the pressure that builds in a pipeline when the slow end cannot absorb what the fast end produces. In a reactive stream, the solution is explicit: the subscriber signals its demand upstream, and the publisher sends only as many items as it requested. Kafka takes a different approach. There is no protocol-level demand signal flowing from consumers back to producers. Instead, Kafka distributes the problem across three separate layers, each with its own configuration surface, and leaves the integration of those layers to you.&lt;/p&gt;

&lt;p&gt;Understanding which layer to reach for, and when, is the practical skill this article develops.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Producer Side: A Bounded Buffer That Blocks
&lt;/h2&gt;

&lt;p&gt;When your application calls &lt;code&gt;producer.send()&lt;/code&gt;, the record does not go directly to the broker. It lands in the &lt;code&gt;RecordAccumulator&lt;/code&gt;, an in-memory buffer the producer client maintains internally. The accumulator organizes records into per-partition deques of &lt;code&gt;ProducerBatch&lt;/code&gt; objects. A background &lt;code&gt;Sender&lt;/code&gt; thread drains ready batches to the appropriate brokers over the network.&lt;/p&gt;

&lt;p&gt;This separation is where producer-side backpressure lives. The accumulator has a finite size, controlled by &lt;code&gt;buffer.memory&lt;/code&gt; (default: 32 MB). As long as the &lt;code&gt;Sender&lt;/code&gt; drains batches faster than your application produces records, everything stays well under that limit. When it does not, because the broker is slow, the network is saturated, or you are simply writing more than the brokers can accept, the buffer fills up.&lt;/p&gt;

&lt;p&gt;At that point, &lt;code&gt;send()&lt;/code&gt; blocks. The calling thread waits inside &lt;code&gt;BufferPool.allocate()&lt;/code&gt; until the &lt;code&gt;Sender&lt;/code&gt; frees enough memory or &lt;code&gt;max.block.ms&lt;/code&gt; (default: 60 seconds) expires. If the timeout fires first, the client throws a &lt;code&gt;BufferExhaustedException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The blocking behavior is the implicit backpressure signal on the producer side. Your application slows down because &lt;code&gt;send()&lt;/code&gt; will not return until there is room. The 60-second default means a slow broker can cause your producers to stall quietly for up to a minute before surfacing an error. This is worth knowing when diagnosing latency spikes that seem to appear without warning.&lt;/p&gt;

&lt;p&gt;In practice, lowering &lt;code&gt;max.block.ms&lt;/code&gt; to 5-10 seconds and handling the resulting exception explicitly gives you faster feedback and a clear place to shed load, rather than waiting for Kafka to silently unblock.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Batching Softens Burst Pressure
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;RecordAccumulator&lt;/code&gt; does not forward records to the &lt;code&gt;Sender&lt;/code&gt; one at a time. It accumulates them into batches, flushing a batch when either it reaches &lt;code&gt;batch.size&lt;/code&gt; bytes or &lt;code&gt;linger.ms&lt;/code&gt; milliseconds have passed since the first record arrived in that batch, whichever comes first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;batch.size&lt;/code&gt; defaults to 16 KB and &lt;code&gt;linger.ms&lt;/code&gt; defaults to 5 ms as of Kafka 4.0 (it was 0 in earlier versions, which caused the producer to send records immediately). A &lt;code&gt;linger.ms&lt;/code&gt; of 0 is right for low-latency use cases but leaves throughput on the table for anything bulk-oriented. A &lt;code&gt;linger.ms&lt;/code&gt; of 5-50 ms allows the accumulator to fill batches more completely, reducing the number of network round trips the &lt;code&gt;Sender&lt;/code&gt; has to make and easing the per-request load on brokers during traffic spikes.&lt;/p&gt;

&lt;p&gt;Compression works at the batch level, so the fuller the batch, the better the ratio. Enabling &lt;code&gt;compression.type&lt;/code&gt; (&lt;code&gt;lz4&lt;/code&gt; or &lt;code&gt;zstd&lt;/code&gt; being the practical choices) on top of sensible batching settings can meaningfully reduce the bytes-per-second your producers push to the network, leaving more headroom before the buffer fills.&lt;/p&gt;




&lt;h2&gt;
  
  
  Broker Quota Throttling
&lt;/h2&gt;

&lt;p&gt;Producer-side buffering protects individual producers from overwhelming themselves. Broker quotas protect the cluster from being overwhelmed by any single client.&lt;/p&gt;

&lt;p&gt;Kafka supports three quota types, each applied per user or client ID on a per-broker basis:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quota type&lt;/th&gt;
&lt;th&gt;What it limits&lt;/th&gt;
&lt;th&gt;Available since&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Produce quota&lt;/td&gt;
&lt;td&gt;Bytes per second written&lt;/td&gt;
&lt;td&gt;Kafka 0.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fetch quota&lt;/td&gt;
&lt;td&gt;Bytes per second read&lt;/td&gt;
&lt;td&gt;Kafka 0.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request quota&lt;/td&gt;
&lt;td&gt;Percentage of broker request-handler CPU time&lt;/td&gt;
&lt;td&gt;Kafka 0.11&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When a client exceeds its quota, the broker does not reject the request. Instead, it computes a throttle delay based on how much the client overshot its limit across a sliding window of 30 one-second buckets. For a produce quota of 10 MB/s, a client that sends 15 MB in one second receives a throttle delay of roughly 500 ms.&lt;/p&gt;

&lt;p&gt;For produce requests, the broker returns the response immediately but includes a non-zero &lt;code&gt;ThrottleTimeMs&lt;/code&gt; field. The client SDK reads this value and pauses outgoing requests for that duration. For fetch requests, the response contains no data and the client backs off before its next poll. The broker also mutes the client's network channel during the delay window, so clients that ignore &lt;code&gt;ThrottleTimeMs&lt;/code&gt; still cannot push more requests through.&lt;/p&gt;

&lt;p&gt;You configure quotas at runtime with no broker restart required:&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;# Set a produce quota of 10 MB/s for a specific client ID&lt;/span&gt;
kafka-configs.sh &lt;span class="nt"&gt;--bootstrap-server&lt;/span&gt; localhost:9092 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--alter&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--add-config&lt;/span&gt; &lt;span class="s1"&gt;'producer_byte_rate=10485760'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--entity-type&lt;/span&gt; clients &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--entity-name&lt;/span&gt; my-producer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to account for in quota math: quotas are per broker, not per cluster. A quota of 50 MB/s on a 6-broker cluster allows up to 300 MB/s of aggregate throughput for that client if its writes are spread evenly across brokers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Consumer Flow Control: Pause, Resume, and Poll Sizing
&lt;/h2&gt;

&lt;p&gt;On the consumption side, Kafka's pull-based model is itself a form of flow control. Your consumer calls &lt;code&gt;poll()&lt;/code&gt; at its own pace; the broker sends back available records up to &lt;code&gt;fetch.max.bytes&lt;/code&gt; (default: 50 MB) and &lt;code&gt;max.partition.fetch.bytes&lt;/code&gt; (default: 1 MB per partition). You control throughput entirely by controlling when and how often you call &lt;code&gt;poll()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two configs bound how much work each poll cycle returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;max.poll.records&lt;/code&gt; (default: 500): the maximum number of records returned per &lt;code&gt;poll()&lt;/code&gt; call&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max.poll.interval.ms&lt;/code&gt; (default: 5 minutes): the maximum time the broker allows between consecutive polls before treating the consumer as dead and triggering a rebalance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your downstream processing is slow, &lt;code&gt;max.poll.records&lt;/code&gt; is the first lever to reach for. Dropping it from 500 to 50 means each poll cycle hands you less work, you return to calling &lt;code&gt;poll()&lt;/code&gt; faster, and you stay well inside the &lt;code&gt;max.poll.interval.ms&lt;/code&gt; window. This alone prevents a large class of rebalance storms that masquerade as "Kafka being slow."&lt;/p&gt;

&lt;p&gt;For more dynamic control, &lt;code&gt;KafkaConsumer&lt;/code&gt; exposes &lt;code&gt;pause(Collection&amp;lt;TopicPartition&amp;gt;)&lt;/code&gt; and &lt;code&gt;resume(Collection&amp;lt;TopicPartition&amp;gt;)&lt;/code&gt;. When you pause a partition, subsequent &lt;code&gt;poll()&lt;/code&gt; calls return no records from that partition. The consumer remains in the group and continues sending heartbeats; it just stops fetching. This is the right tool when your processing is async (thread pools, HTTP calls, database writes) and you need to prevent a downstream queue from growing without bound.&lt;/p&gt;

&lt;p&gt;A practical 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="k"&gt;while&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="nc"&gt;ConsumerRecords&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;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;poll&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;ofMillis&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="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConsumerRecord&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;record&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;processingQueue&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="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// blocks naturally when the queue is full&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;processingQueue&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;HIGH_WATERMARK&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pause&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assignment&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;processingQueue&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;LOW_WATERMARK&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resume&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assignment&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 one thing to watch: paused partitions still count against your session. If the processing thread takes longer than &lt;code&gt;max.poll.interval.ms&lt;/code&gt; to drain below &lt;code&gt;LOW_WATERMARK&lt;/code&gt;, the broker removes the consumer from the group. Set &lt;code&gt;HIGH_WATERMARK&lt;/code&gt; generously enough that draining happens well within that window.&lt;/p&gt;




&lt;h2&gt;
  
  
  Kafka Streams: Backpressure for Free
&lt;/h2&gt;

&lt;p&gt;Kafka Streams applications are simultaneously consumers and producers: they read from input topics, transform records, and write results to output topics. Because they use the standard consumer client under the hood, backpressure emerges naturally from the pull-based poll rhythm.&lt;/p&gt;

&lt;p&gt;If processing slows, the Streams thread takes longer per poll cycle. This means fewer records flow through per unit of time, without any explicit pause/resume logic on your part. The application never accumulates more records in memory than one poll cycle returns.&lt;/p&gt;

&lt;p&gt;You still care about &lt;code&gt;max.poll.records&lt;/code&gt; and &lt;code&gt;commit.interval.ms&lt;/code&gt;. A slow processing stage increases the uncommitted offset window; if a Streams application restarts, it replays all uncommitted records. Shorter commit intervals reduce that replay window, at the cost of more offset commits to the &lt;code&gt;__consumer_offsets&lt;/code&gt; topic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Techniques at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;th&gt;What it protects&lt;/th&gt;
&lt;th&gt;Key config&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Producer buffer blocking&lt;/td&gt;
&lt;td&gt;Producer client&lt;/td&gt;
&lt;td&gt;Slows the caller when broker is slow&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;buffer.memory&lt;/code&gt;, &lt;code&gt;max.block.ms&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch accumulation&lt;/td&gt;
&lt;td&gt;Producer client&lt;/td&gt;
&lt;td&gt;Smooths throughput spikes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;batch.size&lt;/code&gt;, &lt;code&gt;linger.ms&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compression&lt;/td&gt;
&lt;td&gt;Producer client&lt;/td&gt;
&lt;td&gt;Reduces wire bytes, extends buffer headroom&lt;/td&gt;
&lt;td&gt;&lt;code&gt;compression.type&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broker quotas&lt;/td&gt;
&lt;td&gt;Broker&lt;/td&gt;
&lt;td&gt;Prevents noisy clients from monopolizing resources&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;producer_byte_rate&lt;/code&gt;, &lt;code&gt;consumer_byte_rate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poll sizing&lt;/td&gt;
&lt;td&gt;Consumer client&lt;/td&gt;
&lt;td&gt;Bounds records per processing cycle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;max.poll.records&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pause/Resume&lt;/td&gt;
&lt;td&gt;Consumer client&lt;/td&gt;
&lt;td&gt;Dynamic per-partition flow control&lt;/td&gt;
&lt;td&gt;&lt;code&gt;KafkaConsumer.pause()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka Streams pull model&lt;/td&gt;
&lt;td&gt;Streams runtime&lt;/td&gt;
&lt;td&gt;Implicit throttling via poll cadence&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;max.poll.records&lt;/code&gt;, &lt;code&gt;commit.interval.ms&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Where to Start
&lt;/h2&gt;

&lt;p&gt;Begin on the consumer side. Measure your per-message processing time, then set &lt;code&gt;max.poll.records&lt;/code&gt; so one poll cycle's work fits inside &lt;code&gt;max.poll.interval.ms&lt;/code&gt; with room to spare. This one change resolves most consumer-lag issues before you need to touch anything else.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;pause()&lt;/code&gt;/&lt;code&gt;resume()&lt;/code&gt; if your processing is async and you need to protect a downstream queue from growing without bound. Keep your watermarks far enough apart that the processing thread can drain between poll cycles.&lt;/p&gt;

&lt;p&gt;On the producer side, decide how you want failures to surface. The 60-second &lt;code&gt;max.block.ms&lt;/code&gt; default means a stalled producer is invisible for up to a minute. Lowering it to 5-10 seconds and treating &lt;code&gt;BufferExhaustedException&lt;/code&gt; as a load signal gives you faster, actionable feedback. Pair this with appropriate &lt;code&gt;linger.ms&lt;/code&gt; and a compression codec to increase throughput before the buffer becomes the constraint.&lt;/p&gt;

&lt;p&gt;Broker quotas earn their configuration cost in multi-tenant clusters or anywhere you have a mix of batch and interactive workloads sharing the same brokers. A runaway batch ingest job should not be able to saturate the cluster and introduce latency spikes for latency-sensitive consumers.&lt;/p&gt;

&lt;p&gt;Consumer lag, visible through &lt;code&gt;kafka-consumer-groups.sh --describe&lt;/code&gt; or the JMX metric &lt;code&gt;records-lag-max&lt;/code&gt;, is the single health signal that ties all these mechanisms together. Sustained, growing lag tells you the consumers are falling behind before the system enters a failure mode. The root cause might be anywhere in this stack, but the lag metric is where you start the investigation.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>distributedsystems</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Five Spring Boot Patterns That Prevent Production Failures</title>
      <dc:creator>srinivas reddy gouru</dc:creator>
      <pubDate>Sat, 06 Jun 2026 00:33:18 +0000</pubDate>
      <link>https://dev.to/srinivas_gouru_d26dc31f21/five-spring-boot-patterns-that-prevent-production-failures-configuration-shutdown-async-circuit-50e6</link>
      <guid>https://dev.to/srinivas_gouru_d26dc31f21/five-spring-boot-patterns-that-prevent-production-failures-configuration-shutdown-async-circuit-50e6</guid>
      <description>&lt;h2&gt;
  
  
  The Gap Between Tutorial Code and Production Reality
&lt;/h2&gt;

&lt;p&gt;Spring Boot is an opinionated framework for building Java services quickly. It auto-configures your web server, data source, and application context so you can focus on writing business logic rather than plumbing. This article is for engineers who have a Spring Boot service running and want to know which additional patterns separate a service that survives real traffic from one that quietly misbehaves under it.&lt;/p&gt;

&lt;p&gt;Consider a concrete example. A downstream API starts responding slowly. Tomcat threads pile up waiting for a response that never comes, because Spring Boot sets no default read timeout. [1] With 60 threads hung on the slow dependency, only 140 of the default 200 remain to serve real traffic, and throughput drops accordingly. [1] In a busier environment, the pool exhausts entirely, and the service stops accepting new requests without throwing a single exception.&lt;/p&gt;

&lt;p&gt;This failure mode does not appear in any beginner Spring Boot tutorial, and that's not an accident of curriculum design. Tutorials optimise for getting something running. Production optimises for keeping it running when the environment misbehaves: when a downstream service hangs, when a deployment interrupts in-flight requests, when configuration differs between staging and prod, when a dependency starts failing intermittently overnight.&lt;/p&gt;

&lt;p&gt;Five patterns address five distinct categories of that production reality. Externalised configuration with profiles keeps environment-specific values out of your artefact, making changes auditable and safe. Graceful shutdown with lifecycle hooks ensures that a rolling deployment doesn't drop mid-flight requests when the process receives a termination signal. [2] Structured async processing with &lt;code&gt;@Async&lt;/code&gt; and explicit thread pool configuration moves slow work off the request thread, so a sluggish job can't starve the rest of the application. Circuit breaking with Resilience4j gives the service a way to stop calling a failing dependency, rather than letting failures cascade. Health and readiness probes with Actuator tell your orchestration layer whether the service is actually ready to receive traffic, not just whether the process started.&lt;/p&gt;

&lt;p&gt;Each of these patterns is already available in the standard Spring Boot dependency tree. None requires a major architectural change. What they require is knowing they exist, understanding what failure they prevent, and wiring them in before traffic is real enough to expose the gap. The rest of this article covers each pattern in turn: what it does, how to configure it, and the specific failure scenario it prevents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1, Externalised Configuration with Profiles: Eliminating Environment Mismatch
&lt;/h2&gt;

&lt;p&gt;A quieter but common failure happens before the service ever receives real traffic: the application runs fine locally, ships to production, and immediately misbehaves because it's still pointed at the dev database or using in-memory H2 instead of Postgres. The fix isn't discipline alone; it's understanding how Spring Boot resolves configuration values and building your config structure around that resolution order.&lt;/p&gt;

&lt;p&gt;Both &lt;code&gt;.properties&lt;/code&gt; and &lt;code&gt;.yml&lt;/code&gt; formats are supported for Spring Boot configuration files, and the two are functionally equivalent. The examples below use &lt;code&gt;.properties&lt;/code&gt; throughout; if your project uses &lt;code&gt;.yml&lt;/code&gt;, the same keys and values apply with YAML's indentation syntax instead.&lt;/p&gt;

&lt;p&gt;Spring Boot 2 evaluates property sources in a well-defined order of priority. Command-line arguments sit at the top, overriding everything below them. OS environment variables come next. Below those sit the profile-specific property files, and at the bottom sits the base &lt;code&gt;application.properties&lt;/code&gt; bundled inside your jar. This layering is the whole point: the jar is immutable, and every environment customises it by injecting values above the jar's baseline rather than by modifying what's baked in.&lt;/p&gt;

&lt;p&gt;Profile-specific files named &lt;code&gt;application-{profile}.properties&lt;/code&gt; placed &lt;em&gt;outside&lt;/em&gt; the jar take precedence over their counterparts &lt;em&gt;inside&lt;/em&gt; the jar. A Kubernetes deployment can mount an environment-specific &lt;code&gt;application-prod.properties&lt;/code&gt; as a ConfigMap volume, and Spring Boot will pick it up without any code change or rebuild. This is what allows a single deployable artefact to run correctly across dev, staging, and production. &lt;/p&gt;

&lt;p&gt;A typical split looks like this. The base &lt;code&gt;application.properties&lt;/code&gt; holds values that genuinely don't change across environments:&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="c"&gt;# application.properties
&lt;/span&gt;&lt;span class="py"&gt;spring.application.name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;order-service&lt;/span&gt;
&lt;span class="py"&gt;spring.jpa.open-in-view&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;management.endpoints.web.exposure.include&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;health,info,prometheus&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dev profile keeps things frictionless locally:&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="c"&gt;# application-dev.properties
&lt;/span&gt;&lt;span class="py"&gt;spring.datasource.url&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;jdbc:h2:mem:orderdb&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.driver-class-name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;org.h2.Driver&lt;/span&gt;
&lt;span class="py"&gt;spring.jpa.show-sql&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;logging.level.com.example&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;DEBUG&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production tightens everything down:&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="c"&gt;# application-prod.properties
&lt;/span&gt;&lt;span class="py"&gt;spring.datasource.url&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;jdbc:postgresql://${DB_HOST}:5432/orderdb&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.username&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${DB_USER}&lt;/span&gt;
&lt;span class="py"&gt;spring.datasource.password&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
&lt;span class="py"&gt;spring.jpa.show-sql&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;logging.level.com.example&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;WARN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prod file deliberately delegates secrets to environment variables using &lt;code&gt;${...}&lt;/code&gt; placeholders. If &lt;code&gt;DB_PASSWORD&lt;/code&gt; isn't set at startup, Spring Boot fails fast with a clear error rather than starting with a null password that only fails on the first query.&lt;/p&gt;

&lt;p&gt;Profile activation is straightforward. Locally, you add &lt;code&gt;spring.profiles.active=dev&lt;/code&gt; to your IDE run configuration or to a &lt;code&gt;.env&lt;/code&gt; file. In a container, you set the environment variable &lt;code&gt;SPRING_PROFILES_ACTIVE=prod&lt;/code&gt;. A command-line override, &lt;code&gt;--spring.profiles.active=staging&lt;/code&gt;, works for one-off deployments without touching any file.&lt;/p&gt;

&lt;p&gt;One thing worth being explicit about: the profile mechanism is not a secret store. Credentials should come through environment variables or a secrets manager like Vault, with the profile file holding only the placeholder reference. Teams that skip this step often end up with database passwords in version control, which is a much worse problem than a misconfigured datasource URL.&lt;/p&gt;

&lt;p&gt;With the environment mismatch handled, the next failure category is what happens when a pod is killed mid-request, and that requires a different approach entirely.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Pattern 2, Graceful Shutdown with Lifecycle Hooks: Preventing Dirty Mid-Request Kills
&lt;/h2&gt;

&lt;p&gt;When Kubernetes rolls out a new version of your service, it sends SIGTERM to the old pod. Without any additional configuration, the JVM exits immediately on that signal. Any request that was mid-flight, a database write half-committed, a payment being authorised, a file being streamed, gets cut off at the socket level. The client receives a connection-reset error and has no way to know whether the operation succeeded.&lt;/p&gt;

&lt;p&gt;Spring Boot 2.3 introduced a built-in graceful shutdown to address exactly this. [2] Enabling it takes two lines 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;server.shutdown&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;graceful&lt;/span&gt;
&lt;span class="py"&gt;spring.lifecycle.timeout-per-shutdown-phase&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;server.shutdown=graceful&lt;/code&gt;, the embedded server (Tomcat, Jetty, or Undertow) stops accepting new connections the moment SIGTERM arrives, then waits for active requests to complete before allowing the JVM to exit. [2] The &lt;code&gt;timeout-per-shutdown-phase&lt;/code&gt; property sets the ceiling: if in-flight requests haven't finished within 30 seconds, Spring proceeds with the shutdown anyway rather than hanging indefinitely. Tune that timeout to match your slowest plausible request, not your average one.&lt;/p&gt;

&lt;p&gt;Graceful shutdown handles the HTTP layer, but your application likely has other resources that need orderly cleanup: database connection pools, scheduled jobs mid-execution, Kafka consumer loops, open file handles. This is where &lt;code&gt;@PreDestroy&lt;/code&gt; and &lt;code&gt;ContextClosedEvent&lt;/code&gt; earn their place. [3] Annotate a method with &lt;code&gt;@PreDestroy&lt;/code&gt;, and Spring calls it during the bean's destruction phase, after the server has drained. For cross-cutting shutdown logic, flushing a metrics buffer, signalling worker threads to stop, and implementing an &lt;code&gt;ApplicationListener&amp;lt;ContextClosedEvent&amp;gt;&lt;/code&gt; gives you a single, centralised place to coordinate cleanup.&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;MetricsFlushListener&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ApplicationListener&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ContextClosedEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;MetricsBuffer&lt;/span&gt; &lt;span class="n"&gt;buffer&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;MetricsFlushListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MetricsBuffer&lt;/span&gt; &lt;span class="n"&gt;buffer&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;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;;&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;onApplicationEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ContextClosedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flush&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;There is one gap that Spring's built-in graceful shutdown cannot close on its own. Kubernetes removes a pod from the service endpoints list asynchronously after sending SIGTERM. There is a brief window, typically a few seconds, during which the pod is already refusing new connections, but the load balancer is still routing traffic to it. Requests that arrive in that window hit a closed socket. &lt;/p&gt;

&lt;p&gt;The standard mitigation is a Kubernetes &lt;code&gt;preStop&lt;/code&gt; hook that introduces a deliberate delay before the JVM begins shutting down. In distroless images where &lt;code&gt;sleep&lt;/code&gt; is unavailable, an Actuator-backed endpoint solves the same problem: a &lt;code&gt;GET /actuator/preStopHook/{delayInMillis}&lt;/code&gt; endpoint holds the preStop hook open for the specified duration, giving the control plane time to drain the pod from all endpoint slices before SIGTERM actually reaches the application. [4] [4] A delay of 10 to 15 seconds covers most Kubernetes environments; the right value depends on your cluster's endpoint propagation latency.&lt;/p&gt;

&lt;p&gt;Once your service shuts down cleanly, the next failure category is what happens during normal operation when a slow downstream dependency starts consuming all your threads.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Pattern 3, Structured Async Processing with &lt;a class="mentioned-user" href="https://dev.to/async"&gt;@async&lt;/a&gt; and Thread Pool Tuning: Defeating Thread Starvation
&lt;/h2&gt;

&lt;p&gt;The thread-exhaustion scenario from the introduction had a specific cause: every slow downstream call was blocking a Tomcat worker thread, and the application had no way to offload that work elsewhere. Spring's &lt;code&gt;@Async&lt;/code&gt; mechanism exists precisely for this situation, but enabling it naively introduces a different failure mode that is just as damaging.&lt;/p&gt;

&lt;p&gt;When you add &lt;code&gt;@EnableAsync&lt;/code&gt; to a configuration class and annotate a service method with &lt;code&gt;@Async&lt;/code&gt;, Spring wraps the bean in a proxy. Calls to that method from outside the bean are intercepted and submitted to an executor, freeing the calling thread immediately. [5] The Tomcat request thread returns to the pool and can accept the next incoming request while the async work runs separately.&lt;/p&gt;

&lt;p&gt;The danger is in which executor Spring uses by default. Without an explicit executor bean, &lt;code&gt;@Async&lt;/code&gt; falls back to &lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt;, which spawns a brand-new OS thread for every invocation and imposes no upper bound on how many can exist simultaneously. [6] Under load, this trades Tomcat thread exhaustion for JVM thread exhaustion; you get &lt;code&gt;OutOfMemoryError: unable to create new native thread&lt;/code&gt; instead of a timeout, which is harder to diagnose and just as fatal. [6]&lt;/p&gt;

&lt;p&gt;The fix is a named &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; bean with explicit bounds:&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="o"&gt;{&lt;/span&gt;

 &lt;span class="nd"&gt;@Bean&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;"taskExecutor"&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;Executor&lt;/span&gt; &lt;span class="nf"&gt;taskExecutor&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;10&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;25&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;200&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;setThreadNamePrefix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"async-worker-"&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;The three sizing parameters interact in a way that surprises many engineers. [7] The pool starts with &lt;code&gt;corePoolSize&lt;/code&gt; threads and keeps them alive even when idle. New tasks go to the queue once all core threads are busy. Only after the queue fills up does the pool grow toward &lt;code&gt;maxPoolSize&lt;/code&gt;. Setting &lt;code&gt;queueCapacity&lt;/code&gt; to a generous but finite value, 200 in the example above, means excess work is buffered rather than immediately spawning new threads. The &lt;code&gt;CallerRunsPolicy&lt;/code&gt; rejection handler applies natural back-pressure by running rejected tasks on the submitting thread instead of throwing an exception, so when the pool and queue are both full, the caller slows down rather than failing outright. [8]&lt;/p&gt;

&lt;p&gt;One subtlety worth understanding: &lt;code&gt;@Async&lt;/code&gt; works via Spring's proxy mechanism, which means the proxy is only engaged when the call crosses a bean boundary. If a method inside &lt;code&gt;NotificationService&lt;/code&gt; calls another &lt;code&gt;@Async&lt;/code&gt; method on the same &lt;code&gt;NotificationService&lt;/code&gt; instance via &lt;code&gt;this.sendEmail(...)&lt;/code&gt;, the proxy is bypassed entirely, and the method runs synchronously on the caller's thread. To get async dispatch from within the same class, inject a reference to &lt;code&gt;self&lt;/code&gt; via &lt;code&gt;@Autowired&lt;/code&gt; and call through that reference, or move the async method to a separate bean.&lt;/p&gt;

&lt;p&gt;With a bounded executor in place, your async service methods look straightforward:&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;NotificationService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

 &lt;span class="nd"&gt;@Async&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"taskExecutor"&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;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;recipient&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;body&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
 &lt;span class="c1"&gt;// I/O-bound work happens here, off the Tomcat thread&lt;/span&gt;
 &lt;span class="n"&gt;emailClient&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;recipient&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completedFuture&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;Naming the executor explicitly in the annotation (&lt;code&gt;"taskExecutor"&lt;/code&gt;) avoids any ambiguity if you later define multiple executors for different workloads, such as a separate pool for CPU-intensive tasks versus I/O-bound ones.&lt;/p&gt;

&lt;p&gt;Thread pool sizing is not a one-size-fits-all choice. For I/O-bound async work, a larger &lt;code&gt;corePoolSize&lt;/code&gt; relative to your CPU count makes sense because threads spend most of their time waiting on network or disk. For CPU-bound work, setting &lt;code&gt;corePoolSize&lt;/code&gt; to roughly the number of available processors prevents context-switching overhead from eating the gains. Exposing &lt;code&gt;corePoolSize&lt;/code&gt; and &lt;code&gt;maxPoolSize&lt;/code&gt; through &lt;code&gt;application.properties&lt;/code&gt; lets you tune these values per environment without redeploying.&lt;/p&gt;

&lt;p&gt;Bounded async processing resolves the thread starvation problem, but it does not help when the downstream service you're calling starts returning errors rather than slow responses. That failure mode, a dependency that is up but broken, is where circuit breaking becomes essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4, Circuit Breaking with Resilience4j: Stopping Cascading Failures at the Boundary
&lt;/h2&gt;

&lt;p&gt;The previous section showed how unbounded thread pools can cause thread starvation when a downstream call is slow. Circuit breaking is what stops that slow call from becoming everyone's problem. Instead of letting threads pile up waiting for a timeout that may never come, a circuit breaker tracks the error rate of outbound calls and, once failures exceed a threshold, stops making the call at all and returns a fast failure immediately. &lt;/p&gt;

&lt;p&gt;Resilience4j models this with three states. In the &lt;strong&gt;CLOSED&lt;/strong&gt; state, calls pass through normally and outcomes are recorded in a sliding window. Once the failure rate inside that window crosses the configured threshold, the breaker transitions to &lt;strong&gt;OPEN&lt;/strong&gt;, where every call is rejected immediately with a &lt;code&gt;CallNotPermittedException&lt;/code&gt;, no network round-trip, no waiting. After &lt;code&gt;waitDurationInOpenState&lt;/code&gt; elapses, the breaker moves to &lt;strong&gt;HALF_OPEN&lt;/strong&gt; and admits a small number of probe requests. If those succeed, the circuit closes again. If they fail, it opens immediately, and the timer resets. [9] [9]&lt;/p&gt;

&lt;p&gt;To add this to a Spring Boot 3 service, you need three dependencies: the Resilience4j starter, AOP support for the annotations to work, and Actuator for observability. [10]&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
 &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;io.github.resilience4j&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;resilience4j-spring-boot3&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;2.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;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-aop&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="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-actuator&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;Configure the breaker's behavior in &lt;code&gt;application.yml&lt;/code&gt;. The two parameters that matter most in production are &lt;code&gt;slidingWindowSize&lt;/code&gt;, how many recent calls are evaluated, and &lt;code&gt;waitDurationInOpenState&lt;/code&gt;, how long the breaker stays open before probing for recovery. [10]&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;resilience4j&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;circuitbreaker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;instances&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;paymentService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;slidingWindowType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;COUNT_BASED&lt;/span&gt;
 &lt;span class="na"&gt;slidingWindowSize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
 &lt;span class="na"&gt;minimumNumberOfCalls&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;failureRateThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
 &lt;span class="na"&gt;waitDurationInOpenState&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
 &lt;span class="na"&gt;permittedNumberOfCallsInHalfOpenState&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;automaticTransitionFromOpenToHalfOpenEnabled&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;On the Java side, annotate the method that crosses the service boundary, the connector or repository layer, not the controller. [11] Pair it with a fallback method that returns a degraded-but-valid response rather than propagating an exception to the caller.&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;PaymentConnector&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;RestTemplate&lt;/span&gt; &lt;span class="n"&gt;restTemplate&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;PaymentConnector&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RestTemplate&lt;/span&gt; &lt;span class="n"&gt;restTemplate&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;restTemplate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
 &lt;span class="o"&gt;}&lt;/span&gt;

 &lt;span class="nd"&gt;@CircuitBreaker&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;"paymentService"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallbackMethod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"paymentFallback"&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;PaymentResponse&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentRequest&lt;/span&gt; &lt;span class="n"&gt;request&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;restTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;postForObject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
 &lt;span class="s"&gt;"https://fraud-api.example.com/check"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentResponse&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="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;PaymentResponse&lt;/span&gt; &lt;span class="nf"&gt;paymentFallback&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PaymentResponse&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrderId&lt;/span&gt;&lt;span class="o"&gt;())&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="s"&gt;"PENDING_REVIEW"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Payment queued for manual review"&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;The fallback method's signature must match the protected method exactly, with an &lt;code&gt;Exception&lt;/code&gt; or &lt;code&gt;Throwable&lt;/code&gt; parameter appended. Get this wrong and Resilience4j will silently fail to wire the fallback, propagating the original exception instead. [11]&lt;/p&gt;

&lt;p&gt;One configuration detail worth getting right early: &lt;code&gt;minimumNumberOfCalls&lt;/code&gt;. Without it, a single failed request at startup can trip a breaker that has only seen one call. Setting it to at least 5 ensures the failure rate is calculated from a meaningful sample before the circuit opens. [12] Similarly, &lt;code&gt;slowCallDurationThreshold&lt;/code&gt; lets you treat calls that hang for more than, say, two seconds as failures, catching a degraded dependency before it starts actively returning errors. [12]&lt;/p&gt;

&lt;p&gt;Resilience4j publishes the circuit breaker state via the Actuator automatically when you enable it. In &lt;code&gt;application.yml&lt;/code&gt;, expose the &lt;code&gt;circuitbreakers&lt;/code&gt; and &lt;code&gt;health&lt;/code&gt; endpoints and set &lt;code&gt;management.health.circuitbreakers.enabled: true&lt;/code&gt;. A GET to &lt;code&gt;/actuator/circuitbreakers&lt;/code&gt; will show the current state, failure rate, and buffered call count for each named instance, useful for confirming that a circuit has opened in production before you start chasing logs. [10]&lt;/p&gt;

&lt;p&gt;Circuit breaking solves the cascading-failure problem, but it only helps if you can see when it fires. The next section covers how Actuator health and readiness probes make that degradation visible to both your orchestration layer and your on-call team.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Pattern 5, Health and Readiness Probes with Actuator: Making Degradation Visible
&lt;/h2&gt;

&lt;p&gt;A Spring Boot service can be simultaneously "running" and completely broken. The JVM is up, the process responds to pings, Kubernetes sees a healthy pod, and every request returns a 500 because the database connection pool was exhausted twenty minutes ago. Without health probes, this failure is invisible to the platform, and all it can do is keep routing traffic into the problem.&lt;/p&gt;

&lt;p&gt;Spring Boot Actuator addresses this by exposing &lt;code&gt;/actuator/health&lt;/code&gt;, &lt;code&gt;/actuator/health/liveness&lt;/code&gt;, and &lt;code&gt;/actuator/health/readiness&lt;/code&gt; as structured endpoints that aggregate the status of every registered health indicator, database, message broker, disk space, Redis, and more, into a single machine-readable response. The overall status follows a logical AND: if any one component reports DOWN, the aggregate reports DOWN and returns HTTP 503.&lt;/p&gt;

&lt;p&gt;The liveness and readiness distinction matters in practice. A liveness probe should be narrow, checking only that the JVM is responsive, not that downstream dependencies are healthy. If your liveness probe checks the database and the database has a thirty-second hiccup, Kubernetes will restart the pod, which does nothing to fix the database and adds unnecessary churn. [13] The readiness probe is where you add dependency checks, because a NOT READY pod is simply removed from the service's endpoint list rather than restarted. Traffic stops arriving; the pod stays alive and recovers when the dependency comes back. [13]&lt;/p&gt;

&lt;p&gt;Enable both probe endpoints explicitly in &lt;code&gt;application.yml&lt;/code&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;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;health&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;show-details&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
 &lt;span class="na"&gt;probes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;enabled&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;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;liveness&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;livenessState&lt;/span&gt;
 &lt;span class="na"&gt;readiness&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;readinessState,db,diskSpace&lt;/span&gt;
 &lt;span class="na"&gt;health&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;livenessstate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;enabled&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;readinessstate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;enabled&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 point your Kubernetes deployment at the right paths:&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;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;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
 &lt;span class="na"&gt;failureThreshold&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;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;span class="na"&gt;periodSeconds&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;failureThreshold&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;startupProbe&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;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
 &lt;span class="na"&gt;periodSeconds&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;failureThreshold&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;A startup probe is worth treating as required for Spring Boot on Kubernetes. Java applications routinely need 30-60 seconds to initialise, and without one, Kubernetes may declare the pod broken before the application context has even finished loading. The startup probe above allows up to 150 seconds for initialisation; after it passes, the liveness probe takes over.&lt;/p&gt;

&lt;p&gt;Actuator also lets you expose your own health logic. Implement &lt;code&gt;HealthIndicator&lt;/code&gt;, annotate it with &lt;code&gt;@Component&lt;/code&gt;, and Spring will automatically include it in the aggregated response. The bean name minus the "HealthIndicator" suffix becomes the key in the JSON output, so &lt;code&gt;DatabaseHealthIndicator&lt;/code&gt; appears as &lt;code&gt;"database"&lt;/code&gt; in the response.&lt;/p&gt;




&lt;h3&gt;
  
  
  Go-Live Checklist: Which Patterns to Add First
&lt;/h3&gt;

&lt;p&gt;The order depends on what kind of service you are shipping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous HTTP service (REST API, BFF, gateway)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Externalised configuration with profiles, environment parity prevents the entire class of "works locally, broken in prod" incidents.&lt;/li&gt;
&lt;li&gt;Graceful shutdown prevents in-flight requests from being killed on every deploy.&lt;/li&gt;
&lt;li&gt;Circuit breaking with Resilience4j protects your thread pool from slow downstream APIs.&lt;/li&gt;
&lt;li&gt;Actuator health and readiness probes make degradation visible to load balancers and on-call engineers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Async worker or event-driven service (Kafka consumer, job processor)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Externalised configuration with profiles.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@Async&lt;/code&gt; with a bounded &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt;, an unbounded executor will exhaust memory under sustained load.&lt;/li&gt;
&lt;li&gt;Actuator health probes, at minimum, expose readiness so the pod can signal when it has fallen behind or lost its broker connection.&lt;/li&gt;
&lt;li&gt;Graceful shutdown, give in-flight messages time to complete before the consumer stops.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Any service deploying to Kubernetes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All five patterns apply, but start with readiness probes and graceful shutdown together. A pod that starts receiving traffic before its connection pool is ready, or that gets killed mid-request on every rolling deploy, will produce incidents that are genuinely hard to debug, because the failure window is short and the logs look normal. &lt;/p&gt;

&lt;p&gt;The fastest path forward: add &lt;code&gt;spring-boot-starter-actuator&lt;/code&gt; to your dependencies today, enable the probe endpoints, and wire the Kubernetes manifest to &lt;code&gt;/actuator/health/liveness&lt;/code&gt; and &lt;code&gt;/actuator/health/readiness&lt;/code&gt;. That single change makes your service's internal state legible to the platform, and legibility is the prerequisite for everything else.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How Spring AI Works: LLMs, Embeddings, and RAG for Java Engineers</title>
      <dc:creator>srinivas reddy gouru</dc:creator>
      <pubDate>Fri, 05 Jun 2026 16:39:37 +0000</pubDate>
      <link>https://dev.to/srinivas_gouru_d26dc31f21/how-spring-ai-works-llms-embeddings-and-rag-for-java-engineers-4ck5</link>
      <guid>https://dev.to/srinivas_gouru_d26dc31f21/how-spring-ai-works-llms-embeddings-and-rag-for-java-engineers-4ck5</guid>
      <description>&lt;h2&gt;
  
  
  The Java Engineer's AI Problem, and Why Spring AI Exists
&lt;/h2&gt;

&lt;p&gt;Spring AI is a Spring Boot framework that connects Java applications to LLMs and vector stores using the same dependency-injection patterns Spring developers already know. Your team picks up a ticket to add an AI-powered feature to an existing Spring Boot service, maybe a document Q&amp;amp;A tool, or a chat assistant backed by company-internal data. You open a browser and start searching for tutorials. Nearly every result assumes you are working in Python, reaching for LangChain, and standing up a virtualenv. The code samples use FastAPI. The LLM client libraries are pip packages. The entire mental model is Python-first, and the message, whether intentional or not, is that AI development happens somewhere other than where you work.&lt;/p&gt;

&lt;p&gt;This is the friction Spring AI was built to remove. Java is a powerful, deeply established language running enormous amounts of production workloads, and there is no good reason Java engineers should have to detour through a different language just to call a language model. The question "how do I build an AI solution in Java?" deserves a real answer, not "port the Python tutorial yourself."&lt;/p&gt;

&lt;p&gt;Spring AI is that answer. It is an application framework for AI engineering whose explicit goal is to carry Spring ecosystem design principles, portability, modular design, and POJO-centric construction into the AI domain. At its core, it solves a concrete integration problem: connecting your enterprise data and APIs with AI models, using the same patterns you already reach for when connecting to a database or a message broker.&lt;/p&gt;

&lt;p&gt;The project drew inspiration from Python's LangChain and LlamaIndex, but it is not a port of either. The underlying conviction is that generative AI applications will not stay confined to one language. Spring AI was founded on the belief that the next wave of AI-powered software will be ubiquitous across programming languages, and that Java engineers deserve first-class tooling rather than a translation layer bolted on afterwards. &lt;/p&gt;

&lt;p&gt;What makes that practical is the mapping layer Spring AI provides between the AI world and the Spring world. Concepts that feel foreign in isolation, chat models, embeddings, vector stores, prompt templates, tool calls, each get a Spring-native abstraction: an auto-configured bean, a declarative client, a repository interface. You wire them with dependency injection the same way you wire a &lt;code&gt;JdbcTemplate&lt;/code&gt; or a &lt;code&gt;RestClient&lt;/code&gt;. The rest of this article walks through how each of those mappings works, how a request travels from your application code through Spring AI to an LLM and back, and how the framework connects to vector databases for retrieval-augmented generation. By the end, you should be able to read a Spring AI codebase, understand the event flow it describes, and know exactly which dependency to add for each piece of the puzzle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring AI's Core Architecture: Familiar Abstractions, New Capabilities
&lt;/h2&gt;

&lt;p&gt;Spring AI is built on three interlocking design principles, and understanding how they stack together explains why the framework feels so natural to a Spring developer. The principles are not independent: each one depends on the one before it, and together they produce something more useful than any of them would alone.&lt;/p&gt;

&lt;p&gt;The foundation is &lt;strong&gt;portability&lt;/strong&gt;, expressed through a set of provider-agnostic interfaces: &lt;code&gt;ChatModel&lt;/code&gt;, &lt;code&gt;EmbeddingModel&lt;/code&gt;, &lt;code&gt;VectorStore&lt;/code&gt;, &lt;code&gt;ImageModel&lt;/code&gt;, and so on. Every supported AI provider, OpenAI, Anthropic, Google, Amazon Bedrock, Azure, Ollama, implements the same interfaces. Your application code refers only to these abstractions, never to a provider-specific class. Switching from Claude to GPT-4 means updating a dependency and two lines in &lt;code&gt;application.properties&lt;/code&gt;; the &lt;code&gt;ChatModel&lt;/code&gt; you injected into your service doesn't change. That portability only becomes practical, though, if something wires those implementations for you automatically.&lt;/p&gt;

&lt;p&gt;That something is &lt;strong&gt;auto-configuration&lt;/strong&gt;. Spring AI ships a Spring Boot starter for each supported provider, and adding one to your build is enough for the framework to register a fully wired AI client as a bean. You set &lt;code&gt;spring.ai.openai.api-key&lt;/code&gt; in &lt;code&gt;application.properties&lt;/code&gt;, and Spring Boot's auto-configuration creates a &lt;code&gt;ChatModel&lt;/code&gt;, an &lt;code&gt;EmbeddingModel&lt;/code&gt;, and a &lt;code&gt;ChatClient.Builder&lt;/code&gt;, all ready for injection, no manual SDK initialisation required. The same mechanism configures vector stores: add &lt;code&gt;spring-ai-pgvector-store-spring-boot-starter&lt;/code&gt; and the auto-configured &lt;code&gt;EmbeddingModel&lt;/code&gt; is wired into a &lt;code&gt;VectorStore&lt;/code&gt; bean automatically. Without the portable interfaces beneath it, this auto-configuration would produce a tangle of provider-specific beans. Because the interfaces are stable contracts, auto-configuration can produce beans that the rest of your application consumes without caring which provider sits behind them.&lt;/p&gt;

&lt;p&gt;Those two layers together create the conditions for the third principle: &lt;strong&gt;POJO-centric design&lt;/strong&gt;. An LLM response is free-form text, but your service needs typed data. Spring AI's Structured Outputs feature handles the conversion: the &lt;code&gt;ChatClient&lt;/code&gt; appends a system message instructing the model to respond in JSON, then deserialises the result directly into whatever Java type you specify.  The call reads exactly as it would if you were deserialising an HTTP response with &lt;code&gt;RestClient&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="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;City&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;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;zipcode&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

&lt;span class="nc"&gt;City&lt;/span&gt; &lt;span class="n"&gt;capital&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;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"What is the capital of Germany?"&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;entity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;City&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;Without portability, you couldn't reuse this pattern across providers. Without auto-configuration, you'd need to manually construct the converter pipeline. The three principles stack, and the stack pays off in code that treats an LLM as just another external data source, one that returns typed objects, gets injected like any other bean, and can be swapped out through configuration.&lt;/p&gt;

&lt;p&gt;This layered architecture is what every subsequent Spring AI concept, &lt;code&gt;ChatClient&lt;/code&gt; prompts, Advisors, RAG pipelines, and tool calling, is built on top of. The diagram below shows how these layers relate at runtime, from your Spring beans down through the abstraction interfaces to the provider implementations and external services.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Talking to LLMs: ChatClient, Models, and Prompt Templates
&lt;/h2&gt;

&lt;p&gt;The first thing most Spring developers want to do when they pick up Spring AI is send a message to an LLM and get a response back. The entry point for that is &lt;code&gt;ChatClient&lt;/code&gt;, a fluent API that feels instantly familiar if you've ever used &lt;code&gt;WebClient&lt;/code&gt; or &lt;code&gt;RestClient&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Getting there requires three things: the right starter on the classpath, a key in &lt;code&gt;application.properties&lt;/code&gt;, and an injected &lt;code&gt;ChatClient&lt;/code&gt;. For OpenAI, that looks like this:&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;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 properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# application.properties
&lt;/span&gt;&lt;span class="py"&gt;spring.ai.openai.api-key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${OPENAI_API_KEY}&lt;/span&gt;
&lt;span class="py"&gt;spring.ai.openai.chat.options.model&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;gpt-4o&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;SupportService&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;SupportService&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;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="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;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;p&gt;Auto-configuration registers the &lt;code&gt;ChatClient.Builder&lt;/code&gt; bean based on whatever provider starter is on the classpath. You inject the builder, call &lt;code&gt;.build()&lt;/code&gt;, and you're done. Swapping from OpenAI to Azure OpenAI or Mistral is a matter of changing the starter dependency and the corresponding properties; the &lt;code&gt;ChatClient&lt;/code&gt; call site above stays unchanged. &lt;/p&gt;

&lt;p&gt;That portability comes from the &lt;code&gt;ChatModel&lt;/code&gt; interface sitting underneath &lt;code&gt;ChatClient&lt;/code&gt;. Every provider adapter implements &lt;code&gt;ChatModel&lt;/code&gt;, so the same &lt;code&gt;call(Prompt)&lt;/code&gt; method signature works regardless of which backend you've configured. You can also inject &lt;code&gt;ChatModel&lt;/code&gt; directly if you need a lower-level access, but for most application code, &lt;code&gt;ChatClient&lt;/code&gt; is the better choice because it layers on convenience methods for streaming, system messages, and chat memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building structured prompts with PromptTemplate
&lt;/h3&gt;

&lt;p&gt;Simple string questions work fine for exploration, but real features need structure. A customer support bot might need to inject the user's account tier and their most recent order number into every prompt. Hardcoding that with string concatenation gets messy fast. &lt;code&gt;PromptTemplate&lt;/code&gt; solves this the same way Thymeleaf solves HTML templating: you define a string with named placeholders using &lt;code&gt;{}&lt;/code&gt; syntax, then fill them at runtime with a &lt;code&gt;Map&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="nc"&gt;PromptTemplate&lt;/span&gt; &lt;span class="n"&gt;template&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;PromptTemplate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
 &lt;span class="s"&gt;"You are helping a {tier} customer. Their last order was {orderId}. Answer: {question}"&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Prompt&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;template&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="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;"tier"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"premium"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
 &lt;span class="s"&gt;"orderId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ORD-9182"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
 &lt;span class="s"&gt;"question"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userInput&lt;/span&gt;
&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="nc"&gt;Generation&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;chatModel&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="n"&gt;prompt&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, Spring AI uses the StringTemplate engine by Terence Parr to perform the substitution. If curly braces clash with your template content, you can configure alternate delimiters via &lt;code&gt;StTemplateRenderer&lt;/code&gt;. You can also store prompt text in classpath resources and inject it with &lt;code&gt;@Value("classpath:/prompts/support.st")&lt;/code&gt;, which keeps long instructions out of your Java source files entirely.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Prompt&lt;/code&gt; can carry multiple &lt;code&gt;Message&lt;/code&gt; objects, each tagged with a role. The system role gives the model its persona and constraints before the conversation begins; the user role carries the actual question; the assistant role records prior model responses for multi-turn exchanges. Spring AI provides &lt;code&gt;SystemPromptTemplate&lt;/code&gt; and &lt;code&gt;UserMessage&lt;/code&gt; as typed helpers for each role, so you rarely work with the raw enum directly. For stateful conversations where the model needs to remember earlier turns, &lt;code&gt;ChatClient&lt;/code&gt; supports chat memory out of the box; prior messages are automatically included in subsequent calls without manual prompt concatenation.&lt;/p&gt;

&lt;p&gt;Once you can send structured prompts to an LLM, the natural next question is how to give it access to your own data. That's where embeddings and vector stores come in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embeddings and Vector Stores: Giving the LLM Long-Term Memory
&lt;/h2&gt;

&lt;p&gt;An embedding is a fixed-length array of floating-point numbers that represents the meaning of a piece of text. When you ask an embedding model to process the sentence "What is the refund policy?", it returns something like a 1536-element vector of decimal values. That vector encodes the semantic content of the sentence, not its exact words. Two sentences that mean the same thing will produce vectors that sit close together in that high-dimensional space, even if they share no vocabulary. That proximity is what makes similarity search possible.&lt;/p&gt;

&lt;p&gt;You do not need to understand the mathematics behind this to use it in a Spring application. What matters is the operational picture: you have text, you convert it to a vector, and you store or compare that vector.&lt;/p&gt;

&lt;h3&gt;
  
  
  EmbeddingModel: The Spring Abstraction
&lt;/h3&gt;

&lt;p&gt;Spring AI wraps embedding providers behind the &lt;code&gt;EmbeddingModel&lt;/code&gt; interface, which follows the same design philosophy as &lt;code&gt;ChatModel&lt;/code&gt;. Auto-configuration wires a provider-specific implementation into your &lt;code&gt;ApplicationContext&lt;/code&gt;, and your code depends only on the interface. Calling &lt;code&gt;embeddingModel.embed("some text")&lt;/code&gt; returns a &lt;code&gt;float[]&lt;/code&gt; at whatever dimensionality the underlying model uses. For batch workloads, &lt;code&gt;embedForResponse(List&amp;lt;String&amp;gt; texts)&lt;/code&gt; returns all vectors in one round trip, which matters when you are processing thousands of documents at startup.&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;DocumentIndexer&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;EmbeddingModel&lt;/span&gt; &lt;span class="n"&gt;embeddingModel&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;DocumentIndexer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EmbeddingModel&lt;/span&gt; &lt;span class="n"&gt;embeddingModel&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;embeddingModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embeddingModel&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;float&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;vectorize&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;text&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;embeddingModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;embed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;Swapping from OpenAI's &lt;code&gt;text-embedding-ada-002&lt;/code&gt; to a locally hosted Ollama model is a one-line change in &lt;code&gt;application.properties&lt;/code&gt;. The dimensionality of the output will differ, but the interface stays identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  VectorStore: One API, Many Databases
&lt;/h3&gt;

&lt;p&gt;Storing and searching vectors requires a database that understands cosine similarity or dot-product distance. Spring AI introduces &lt;code&gt;VectorStore&lt;/code&gt;, a single portable interface over a wide fleet of supported backends: PostgreSQL via PGVector, Redis, MongoDB Atlas, Chroma, Milvus, Pinecone, Weaviate, Neo4j, Oracle, Qdrant, Apache Cassandra, and Azure Vector Search.&lt;/p&gt;

&lt;p&gt;The API has two primary operations. &lt;code&gt;vectorStore.add(List&amp;lt;Document&amp;gt; documents)&lt;/code&gt; takes Spring AI &lt;code&gt;Document&lt;/code&gt; objects, embeds them automatically, and persists the resulting vectors alongside the original text and any metadata you attach. &lt;code&gt;vectorStore.similaritySearch(SearchRequest.query("refund policy").withTopK(5))&lt;/code&gt; retrieves the five documents whose vectors sit closest to the query vector, returning plain &lt;code&gt;Document&lt;/code&gt; objects ready to inject into a prompt.&lt;/p&gt;

&lt;p&gt;Beyond basic search, Spring AI adds a SQL-like metadata filter syntax that works portably across every supported provider. You can write &lt;code&gt;SearchRequest.query("refund policy").withFilterExpression("category == 'billing' &amp;amp;&amp;amp; year &amp;gt;= 2024")&lt;/code&gt;, and the library translates that expression into whatever native filter the underlying store supports, whether that is a Postgres &lt;code&gt;WHERE&lt;/code&gt; clause or a Pinecone metadata filter object. That portability means you can develop against an in-process Chroma instance and deploy against PGVector in production without changing application code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why These Two Primitives Matter
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;EmbeddingModel&lt;/code&gt; converts meaning into numbers; &lt;code&gt;VectorStore&lt;/code&gt; stores those numbers and finds the closest ones on demand. Together, they give an LLM access to knowledge that was never in its training data, your documentation, your customer records, and your internal policies. In the next section, those two primitives become the first two steps in a Retrieval-Augmented Generation pipeline that feeds retrieved context directly into the model's prompt.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  RAG Pipelines: Wiring Documents into LLM Answers
&lt;/h2&gt;

&lt;p&gt;Retrieval-Augmented Generation (RAG) is the pattern that closes the biggest gap in stock LLM behaviour: a model trained on public data knows nothing about your internal documents, product catalogue, or support tickets. RAG solves this by retrieving the relevant text at request time and handing it to the model as context, so the answer is grounded in your data rather than in whatever the model happened to learn during pre-training. Spring AI makes this pattern a first-class citizen, and it maps naturally onto the embedding and vector-store primitives covered in the previous section.&lt;/p&gt;

&lt;p&gt;The pipeline splits into two distinct phases. In the ingestion phase, you load raw documents, split them into chunks, embed each chunk, and write the resulting vectors into a vector store. In the query phase, you embed the user's question, run a similarity search against those vectors to retrieve the top-K most relevant chunks, inject those chunks into the prompt as additional context, and send the augmented prompt to the LLM. Everything between "user typed a question" and "LLM produced a grounded answer" runs inside Spring-managed beans, which means you configure it exactly the way you'd configure a data source or a REST client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ingestion: Loading and Splitting Documents
&lt;/h3&gt;

&lt;p&gt;Spring AI ships a &lt;code&gt;DocumentReader&lt;/code&gt; abstraction with built-in implementations for PDF, plain text, Markdown, HTML, and JSON. You load documents, pipe them through a &lt;code&gt;TokenTextSplitter&lt;/code&gt; (or a custom splitter), and call &lt;code&gt;vectorStore.add(documents)&lt;/code&gt;. The &lt;code&gt;VectorStore&lt;/code&gt; implementation calls your configured &lt;code&gt;EmbeddingModel&lt;/code&gt; automatically; you don't wire the embedding call by hand.&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;ApplicationRunner&lt;/span&gt; &lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DocumentReader&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
 &lt;span class="nc"&gt;TokenTextSplitter&lt;/span&gt; &lt;span class="n"&gt;splitter&lt;/span&gt;&lt;span class="o"&gt;,&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="o"&gt;{&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;args&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;Document&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reader&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="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;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;splitter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&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;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&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;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Ingested "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;chunks&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="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" chunks."&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;Run this once at startup (or on a schedule), and the vector store is ready to serve queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query: Retrieval and Prompt Augmentation
&lt;/h3&gt;

&lt;p&gt;On the query side, the &lt;code&gt;QuestionAnswerAdvisor&lt;/code&gt; is the highest-level abstraction Spring AI provides. You attach it to a &lt;code&gt;ChatClient&lt;/code&gt;, pass in the &lt;code&gt;VectorStore&lt;/code&gt;, and it handles the retrieve-then-augment flow automatically. The advisor embeds the incoming question, queries the vector store for similar chunks, and rewrites the prompt to include those chunks before the call reaches the model.&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;ChatClient&lt;/span&gt; &lt;span class="nf"&gt;ragClient&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="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="o"&gt;{&lt;/span&gt;
 &lt;span class="k"&gt;return&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;defaultAdvisors&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;QuestionAnswerAdvisor&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="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;// At request time:&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ragClient&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;userQuestion&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The advisor's default behaviour retrieves the top four chunks and appends them to the system message. You can tune &lt;code&gt;k&lt;/code&gt;, apply metadata filters (useful when you've segmented documents by tenant or product line), or replace the default prompt template entirely by supplying your own &lt;code&gt;PromptTemplate&lt;/code&gt; to the advisor constructor.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Embeddings Are Actually Doing
&lt;/h3&gt;

&lt;p&gt;When the advisor receives a question, it calls &lt;code&gt;embeddingModel.embed(question)&lt;/code&gt; to produce a vector, then calls &lt;code&gt;vectorStore.similaritySearch(SearchRequest.query(question).withTopK(4))&lt;/code&gt;. The vector store computes cosine similarity (or an equivalent metric, depending on the backend) between the query vector and all stored chunk vectors, and returns the closest matches. Because the embeddings encode semantic meaning rather than literal keyword overlap, a question about "cancellation policy" will match a chunk that uses the phrase "how to end your subscription", something a keyword search would miss entirely.&lt;/p&gt;

&lt;p&gt;The result is a system where your LLM's answers are constrained by evidence drawn from your own documents, which reduces hallucination and keeps the model from confidently citing things it simply doesn't know. For most production Java applications, this is the pattern that makes the difference between a demo and something you'd trust at scale.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Tool Calling: Letting the LLM Invoke Your Spring Beans
&lt;/h2&gt;

&lt;p&gt;RAG pipelines give the LLM access to stored documents, but documents are static snapshots. When a user asks "what's the current balance on account 4821?" or "book a meeting for tomorrow at 2 pm," the model needs to reach into live systems. Tool calling is how that happens.&lt;/p&gt;

&lt;p&gt;The core idea is straightforward: instead of answering a question itself, the LLM can pause mid-response and say, "I need to call a function to answer this." Spring AI intercepts that signal, executes the corresponding Java method, and sends the result back to the model so it can complete its response. From the user's perspective, the answer just arrives. From the developer's perspective, the model is calling a Spring bean. &lt;/p&gt;

&lt;h3&gt;
  
  
  How the Round-Trip Works
&lt;/h3&gt;

&lt;p&gt;The sequence goes like this: your application sends a prompt along with a list of available tools, each described by name, purpose, and parameter schema. The model evaluates whether any tool is relevant to the request. If it decides to use one, it returns a structured tool-call request rather than a natural-language answer. Spring AI catches that response, looks up the registered Java method by name, deserialises the arguments, invokes the method, and then sends the result back to the model as a follow-up message. The model reads the result and continues generating the final answer. The whole loop is invisible to the caller.&lt;/p&gt;

&lt;h3&gt;
  
  
  Registering a Tool
&lt;/h3&gt;

&lt;p&gt;Spring AI lets you register tools in two ways: as annotated beans or as inline lambdas passed directly to the &lt;code&gt;ChatClient&lt;/code&gt;. The annotation approach maps cleanly to existing service classes:&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;AccountTools&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

 &lt;span class="nd"&gt;@Tool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Returns the current balance for a given account 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;BigDecimal&lt;/span&gt; &lt;span class="nf"&gt;getAccountBalance&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;accountId&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;accountRepository&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;accountId&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;Account:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getBalance&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;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unknown account: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;accountId&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;You then attach it when building the client call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;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;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"What is the balance on account 4821?"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accountTools&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring AI reads the &lt;code&gt;@Tool&lt;/code&gt; annotation to generate the JSON schema that the model receives, so the model knows exactly what parameters to pass and what the function does. You do not write any schema by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guardrails Around Tool Calls
&lt;/h3&gt;

&lt;p&gt;Because the model is now driving real method invocations, safety controls matter. Spring AI supports input and output guardrails at the model-provider level, which means you can validate what the model is asking to call before the method executes, and you can filter or transform the result before it goes back to the model. In practice, this looks like middleware: you intercept the tool-call request, check it against your business rules, and either allow it, reject it with a safe fallback, or modify the parameters. That keeps a misbehaving model from passing malformed inputs straight into your database layer.&lt;/p&gt;

&lt;p&gt;Tool calling is also where Spring AI's model portability pays off most noticeably. The same &lt;code&gt;@Tool&lt;/code&gt;-annotated bean works against OpenAI, Anthropic, Google Gemini, or a local Ollama model. The framework translates the tool schema into whatever wire format each provider expects, so you are not locked into one vendor's function-calling API. Once your tools are registered, switching models is a configuration change, not a rewrite.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Agentic Patterns: Workflows vs. Autonomous Agents
&lt;/h2&gt;

&lt;p&gt;Tool calling, covered in the previous section, is the mechanism that enables agents. The next question is how much control you hand to the LLM once those tools are available.&lt;/p&gt;

&lt;p&gt;Spring AI draws a clear line between two types of agentic systems. In a &lt;strong&gt;workflow&lt;/strong&gt;, LLMs and tools are orchestrated through predefined code paths. Your Java code decides when to call the model, which tools are eligible, and what happens with the result. In an &lt;strong&gt;agent&lt;/strong&gt;, the LLM itself dynamically decides which tools to invoke and in what sequence, with your code acting more as an execution host than a director.&lt;/p&gt;

&lt;p&gt;The distinction matters practically. A workflow is deterministic by construction: given the same inputs, you follow the same execution path, which makes it easy to test, audit, and explain to stakeholders. An autonomous agent trades that predictability for flexibility. You describe a goal and let the model reason about how to reach it, which is powerful but introduces non-determinism that can be hard to manage in a production system with SLA requirements.&lt;/p&gt;

&lt;p&gt;Spring AI's approach here is deliberately aligned with Anthropic's research on building effective agents, which emphasises simplicity and composability over complex orchestration frameworks. The practical takeaway from that research is that a well-structured workflow better serves most production use cases than a fully autonomous agent, even when autonomous agents feel like the more exciting option. Spring AI's &lt;code&gt;agentic-patterns&lt;/code&gt; reference project implements both styles, giving you concrete starting points for each. &lt;/p&gt;

&lt;p&gt;For a backend Java engineer, this framing maps onto patterns you already know. A workflow resembles a service method with a clear call graph: call the LLM to classify intent, branch on the result, call a tool, and return a structured response. An agent resembles an event loop: the LLM produces a tool call, you execute it, you feed the result back to the model, and you repeat until the model signals it is done. Spring AI handles that loop through its &lt;code&gt;ChatClient&lt;/code&gt; and tool registration infrastructure, the same APIs you use for single-shot requests, just called iteratively inside a loop that the model controls.&lt;/p&gt;

&lt;p&gt;In practice, most teams start with workflow patterns because the control flow is visible in code and failures are easy to reproduce. The agentic loop becomes worthwhile when the task genuinely requires open-ended reasoning, a research assistant that decides which APIs to query based on partial results, for example, or a code-review agent that chooses which static-analysis tools to run based on the languages it detects in a PR. For everything else, the workflow keeps the model in a supporting role and your Java code in the driver's seat, which is usually where you want it in an enterprise context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Model Configuration and Provider Portability
&lt;/h2&gt;

&lt;p&gt;Real enterprise applications rarely settle on a single LLM. You might use a fast, inexpensive model for short classification tasks and a more capable model for complex summarisation, or you might want a fallback path so that when one provider's API is unavailable, the application degrades gracefully rather than returning an error. Spring AI handles both scenarios through the same auto-configuration and dependency injection patterns you already use everywhere else in your Spring Boot application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Switching Providers With Configuration Alone
&lt;/h3&gt;

&lt;p&gt;The most direct form of portability is the ability to change providers without touching your application code. Because &lt;code&gt;ChatClient&lt;/code&gt; and &lt;code&gt;ChatModel&lt;/code&gt; are interfaces, the concrete implementation is determined at startup by whichever starter is on the classpath and how &lt;code&gt;application.properties&lt;/code&gt; is set. Swapping from OpenAI to Anthropic's Claude is primarily a two-part change: replace the starter dependency in your build file, and update your properties.&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="c"&gt;# OpenAI configuration (active)
&lt;/span&gt;&lt;span class="py"&gt;spring.ai.openai.api-key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${OPENAI_API_KEY}&lt;/span&gt;
&lt;span class="py"&gt;spring.ai.openai.chat.options.model&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;gpt-4o&lt;/span&gt;

&lt;span class="c"&gt;# Anthropic Claude configuration (alternative, comment out the OpenAI block above
# and uncomment these lines to switch providers; do not use both at once)
# spring.ai.anthropic.api-key=${ANTHROPIC_API_KEY}
# spring.ai.anthropic.chat.options.model=claude-3-5-sonnet-20241022
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your &lt;code&gt;@Service&lt;/code&gt; classes that inject &lt;code&gt;ChatModel&lt;/code&gt; or &lt;code&gt;ChatClient&lt;/code&gt; remain untouched. This is the same bet Spring has always made with its abstraction layers: write to the interface, configure the implementation externally.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How Spring Security works with JWT, OAuth2, and SSO</title>
      <dc:creator>srinivas reddy gouru</dc:creator>
      <pubDate>Tue, 26 May 2026 17:43:05 +0000</pubDate>
      <link>https://dev.to/srinivas_gouru_d26dc31f21/how-spring-security-works-with-jwt-oauth2-and-sso-5fil</link>
      <guid>https://dev.to/srinivas_gouru_d26dc31f21/how-spring-security-works-with-jwt-oauth2-and-sso-5fil</guid>
      <description>&lt;h2&gt;
  
  
  The 401 You Never Wrote: What Spring Security Actually Does Out of the Box
&lt;/h2&gt;

&lt;p&gt;Spring Security is a framework that intercepts every HTTP request entering your application and enforces authentication and authorisation rules before that request ever reaches a controller. You do not write the interception logic yourself; adding the &lt;code&gt;spring-boot-starter-security&lt;/code&gt; dependency to a Spring Boot project is enough to activate a default configuration that locks down every endpoint and returns a 401 (or redirects to &lt;code&gt;/login&lt;/code&gt;) for any unauthenticated caller. No annotations, no filters, no &lt;code&gt;@PreAuthorize&lt;/code&gt;, just a single dependency, and suddenly your previously open API is asking for credentials.&lt;/p&gt;

&lt;p&gt;That out-of-the-box behaviour surprises a lot of engineers the first time they see it, but it reflects a deliberate design choice: security should be the default, not something you opt into. The alternative, checking credentials inside each controller method, means one forgotten check equals one unprotected route. Spring Security removes that category of mistake entirely by applying rules uniformly at the HTTP layer, before a single line of your business logic runs. &lt;/p&gt;

&lt;p&gt;What makes this possible is the &lt;strong&gt;security filter chain&lt;/strong&gt;. Spring Security is not a single interceptor sitting in front of your application; it is a carefully ordered sequence of servlet filters, each responsible for one concern. One filter handles CSRF token validation. Another manages session creation and lookup. Another reads the &lt;code&gt;Authorisation&lt;/code&gt; header and tries to resolve a credential. Another checks whether the resolved identity actually has permission to reach the requested URL. These filters run in a fixed order on every incoming request, and each one can either pass the request downstream or short-circuit the chain with an error response.&lt;/p&gt;

&lt;p&gt;At the centre of this pipeline sits the &lt;code&gt;SecurityContext&lt;/code&gt;. By the time the authorisation filters run, the chain expects someone to have placed an &lt;code&gt;Authentication&lt;/code&gt; object into the context, a wrapper that holds the verified identity (the &lt;em&gt;principal&lt;/em&gt;) and the roles or authorities it carries. If the context is empty when the authorisation filter runs, the chain treats the caller as anonymous. If the requested URL requires an authenticated user, the request is rejected before your controller is ever invoked.&lt;/p&gt;

&lt;p&gt;This is the mental model that makes all of Spring Security coherent: a pipeline of filters, a shared context object, and one job delegated to whichever filter is responsible for your chosen credential mechanism. Every token mechanism you'll configure, JWT, OAuth2, SSO, is simply a different strategy for putting that principal into the context. Get that strategy wrong, or register it at the wrong point in the chain, and you'll keep seeing 401s that your controllers never threw.&lt;/p&gt;

&lt;h2&gt;
  
  
  The FilterChain: Spring Security's Request Processing Pipeline
&lt;/h2&gt;

&lt;p&gt;Every HTTP request that reaches a Spring application passes through a structure called the &lt;code&gt;SecurityFilterChain&lt;/code&gt; before it ever touches a controller. Understanding this chain is what separates confident Spring Security configuration from trial-and-error annotation hunting.&lt;/p&gt;

&lt;p&gt;Spring Security enters the picture through a &lt;code&gt;DelegatingFilterProxy&lt;/code&gt;, a thin Servlet filter that the framework registers with the container at startup. [1] Its only job is to hand the request off to Spring's application context, where the real work happens inside a &lt;code&gt;SecurityFilterChain&lt;/code&gt;: an ordered list of Spring-managed filters, each responsible for one specific concern. [1] [2] Think of it as a pipeline where each stage either passes the request forward, modifies it, or short-circuits the whole chain by writing a response directly, returning a &lt;code&gt;401&lt;/code&gt;, for example, before the request ever reaches your business logic.&lt;/p&gt;

&lt;p&gt;The order of filters in that chain is not arbitrary. Spring Security ships with defaults that place CSRF protection early, session management in the middle, and the authorisation check (&lt;code&gt;AuthorizationFilter&lt;/code&gt;) near the end. Authentication filters, wherever you insert them, must run before that authorisation check, because authorisation reads from something the authentication filter writes: the &lt;code&gt;SecurityContextHolder&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;SecurityContextHolder&lt;/code&gt; is a thread-local container that holds the &lt;code&gt;SecurityContext&lt;/code&gt;, and the &lt;code&gt;SecurityContext&lt;/code&gt; holds the &lt;code&gt;Authentication&lt;/code&gt; object for the current request. [1] [1] When an authentication filter validates a credential and calls &lt;code&gt;SecurityContextHolder.getContext().setAuthentication(...)&lt;/code&gt;, it is essentially leaving a note for every filter that comes after it: "this request belongs to a principal with these roles." The authorisation filter at the end of the chain reads that note and decides whether the principal's roles satisfy the rules you configured for that endpoint. If no authentication filter populates the context, the authorisation filter finds an anonymous token and rejects the request accordingly.&lt;/p&gt;

&lt;p&gt;This design has a practical consequence for configuration. When you call &lt;code&gt;http.addFilterBefore(myFilter, UsernamePasswordAuthenticationFilter.class)&lt;/code&gt; or &lt;code&gt;addFilterAfter&lt;/code&gt;, you are specifying exactly where in this ordered pipeline your logic runs. [2] Getting that position wrong is one of the most common sources of mysterious &lt;code&gt;403&lt;/code&gt; responses, because a filter that runs after the authorisation check can no longer influence the outcome of that check.&lt;/p&gt;

&lt;p&gt;Each of the three token mechanisms covered in the next sections plugs into this chain at a different point and populates the &lt;code&gt;SecurityContextHolder&lt;/code&gt; in a different way. A JWT filter reads the &lt;code&gt;Authorisation&lt;/code&gt; header, validates the token cryptographically, and sets the authentication directly, all within a single filter. OAuth2's resource server support does something similar but delegates signature verification to a remote or locally cached JWK set. SSO flows are different again: they redirect the browser entirely, establish a session on return, and then rely on a session-reading filter to restore the &lt;code&gt;SecurityContext&lt;/code&gt; on subsequent requests. Same chain, same &lt;code&gt;SecurityContextHolder&lt;/code&gt; contract, three different plug-in points. That consistency is what makes Spring Security composable once you internalise the pipeline model.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  JWT Authentication: Stateless Token Validation in the Filter Chain
&lt;/h2&gt;

&lt;p&gt;With the filter chain's structure in mind, it's worth seeing exactly how a real token mechanism plugs into it. JWT is the most common starting point, partly because it requires no session storage and no round-trip to an authorisation server; the token itself carries everything the application needs to make an access decision.&lt;/p&gt;

&lt;p&gt;When a client sends a request to a secured endpoint, it includes a bearer token in the &lt;code&gt;Authorisation&lt;/code&gt; header. Spring Security doesn't know what to do with that header on its own; nothing in the default chain extracts a JWT. That gap is your extension point. You create a filter that extends &lt;code&gt;OncePerRequestFilter&lt;/code&gt;, extract the token from the header, validate its signature and expiry, and then construct an &lt;code&gt;Authentication&lt;/code&gt; object that you write into the &lt;code&gt;SecurityContextHolder&lt;/code&gt;. [1] From that moment forward in the chain, the request looks just like any other authenticated request; downstream filters and your controllers see a populated security context and never need to know a JWT was involved.&lt;/p&gt;

&lt;p&gt;Position matters here. Your custom filter must be registered &lt;em&gt;before&lt;/em&gt; &lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt; in the chain, which you do with &lt;code&gt;addFilterBefore&lt;/code&gt; in your &lt;code&gt;SecurityFilterChain&lt;/code&gt; configuration. If it runs after the authorisation filters have already evaluated the request, the &lt;code&gt;SecurityContext&lt;/code&gt; will be empty at the moment it counts, and the request will be rejected as unauthenticated regardless of whether the token was valid.&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;@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;JwtAuthenticationFilter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;OncePerRequestFilter&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;JwtUtility&lt;/span&gt; &lt;span class="n"&gt;jwtUtility&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;UserDetailsService&lt;/span&gt; &lt;span class="n"&gt;userDetailsService&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;protected&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;doFilterInternal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
 &lt;span class="nc"&gt;HttpServletResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
 &lt;span class="nc"&gt;FilterChain&lt;/span&gt; &lt;span class="n"&gt;filterChain&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;ServletException&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IOException&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;authHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AUTHORIZATION&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;authHeader&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="n"&gt;authHeader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startsWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bearer "&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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="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;String&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authHeader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&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;username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwtUtility&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;extractUsername&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getAuthentication&lt;/span&gt;&lt;span class="o"&gt;()&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="nc"&gt;UserDetails&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;userDetailsService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loadUserByUsername&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&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;jwtUtility&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isTokenValid&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="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
 &lt;span class="nc"&gt;UsernamePasswordAuthenticationToken&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
 &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UsernamePasswordAuthenticationToken&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="kc"&gt;null&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;getAuthorities&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;setDetails&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;WebAuthenticationDetailsSource&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;buildDetails&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
 &lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setAuthentication&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="o"&gt;}&lt;/span&gt;
 &lt;span class="o"&gt;}&lt;/span&gt;
 &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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;Because the server stores no session state, every request must be validated independently. The filter re-verifies the signature and the expiry claim on each call. This is what makes JWT genuinely stateless: the token is self-contained, and the server-side footprint is just the public key or shared secret used for verification.&lt;/p&gt;

&lt;p&gt;The filter is also the right place to handle more advanced concerns. Token revocation, for example, can be layered in by checking a blocklist inside the same &lt;code&gt;doFilterInternal&lt;/code&gt; method before the &lt;code&gt;Authentication&lt;/code&gt; object is written to the context. Compromised password detection follows the same pattern. The filter becomes the single choke point for every token lifecycle decision, which keeps your authorisation logic clean and your business controllers unaware of authentication mechanics.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  OAuth2 Login &amp;amp; Resource Server: Delegating Trust to an Authorisation Server
&lt;/h2&gt;

&lt;p&gt;Where JWT authentication is a two-party handshake between your application and the token, OAuth2 introduces a third party: an authorisation server that your application has to coordinate with in real time. Spring Security handles all of that coordination, but you need to tell it which role your application is playing, because the configuration and the filter chain behaviour are meaningfully different depending on the answer.&lt;/p&gt;

&lt;p&gt;Spring Security's OAuth2 support draws a clean line between two roles. &lt;code&gt;oauth2Login()&lt;/code&gt; configures your application as an &lt;strong&gt;OAuth2 client&lt;/strong&gt;: it performs the redirect to the authorization server, receives the callback, exchanges the authorization code for tokens, and logs the user in. &lt;code&gt;oauth2ResourceServer()&lt;/code&gt; configures your application as an &lt;strong&gt;API backend&lt;/strong&gt; that accepts and validates bearer tokens issued by an external authorization server. These two modes are not interchangeable. A browser-facing web app typically uses &lt;code&gt;oauth2Login()&lt;/code&gt;; a REST API consumed by other services typically uses &lt;code&gt;oauth2ResourceServer()&lt;/code&gt;. Some architectures need both in separate security filter chains.&lt;/p&gt;

&lt;p&gt;In the Authorisation Code flow, &lt;code&gt;OAuth2LoginAuthenticationFilter&lt;/code&gt; does the heavy lifting on the client side. When the authorisation server redirects the browser back to your app with a &lt;code&gt;?code=...&lt;/code&gt; parameter, this filter intercepts the request, calls the authorisation server's token endpoint to exchange the code for an access token and ID token, loads the user's identity from the user-info endpoint or from the ID token directly, and populates the &lt;code&gt;SecurityContext&lt;/code&gt;. You do not write any of that exchange logic yourself. The minimal configuration to enable it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableWebSecurity&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;SecurityConfig&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;SecurityFilterChain&lt;/span&gt; &lt;span class="nf"&gt;filterChain&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="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;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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;oauth2Login&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="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;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;Spring Boot auto-configuration picks up the client registration details from &lt;code&gt;application.yml&lt;/code&gt;, client ID, client secret, authorisation URI, and token URI, so the Java config stays short.&lt;/p&gt;

&lt;p&gt;On the resource server side, &lt;code&gt;oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)&lt;/code&gt; swaps in a &lt;code&gt;BearerTokenAuthenticationFilter&lt;/code&gt; that reads the &lt;code&gt;Authorisation: Bearer...&lt;/code&gt; header, validates the JWT's signature against the authorisation server's published JWKS endpoint, and populates the &lt;code&gt;SecurityContext&lt;/code&gt; with the decoded claims. Introspection, calling the authorisation server's introspect endpoint on every request instead of validating locally, is also supported, and is the right choice when you need real-time token revocation checks rather than relying on a short expiry window.&lt;/p&gt;

&lt;p&gt;Token lifecycle is another area where Spring Security saves work. When a stored access token has expired, the OAuth2 client support automatically attempts a refresh token grant before forwarding the request. Your application layer never sees the expired token.&lt;/p&gt;

&lt;p&gt;One configuration pitfall is worth calling out explicitly. If you want to run custom logic after the token endpoint responds, setting a cookie from the token, for example, that logic must be placed inside the &lt;code&gt;SecurityFilterChain&lt;/code&gt; at the correct position using &lt;code&gt;addFilterAfter()&lt;/code&gt;, not in a plain servlet filter sitting outside Spring Security. Filters registered outside the chain execute at a different point in the request lifecycle and cannot reliably observe the outcome of Spring Security's token processing.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  SSO with Spring Security: Session-Backed Identity Federation
&lt;/h2&gt;

&lt;p&gt;The sharpest difference between SSO and the JWT path covered earlier is not in the configuration, it's in what survives a request boundary. JWT authentication is stateless: every request carries its own proof of identity in the &lt;code&gt;Authorization&lt;/code&gt; header, and the filter validates that proof from scratch each time. SSO via &lt;code&gt;oauth2Login()&lt;/code&gt; works the opposite way. Once a user completes the redirect dance with the identity provider, Spring Security stores the resulting &lt;code&gt;Authentication&lt;/code&gt; object in the HTTP session, and every subsequent request is resolved against that session rather than against a fresh token. &lt;/p&gt;

&lt;p&gt;That session-backed model is what makes SSO feel seamless to the end user: they authenticate once with Google or GitHub, get redirected back to your application, and from that point on the browser's session cookie is the credential. The identity provider is not consulted again until the session expires or the user explicitly signs out.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;application.yml&lt;/code&gt; registration for a Google-backed SSO looks like this:&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;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;oauth2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;registration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;google&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YOUR_CLIENT_ID&lt;/span&gt;
 &lt;span class="na"&gt;client-secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YOUR_CLIENT_SECRET&lt;/span&gt;
 &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openid, profile, email&lt;/span&gt;
 &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;google&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;authorization-uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://accounts.google.com/o/oauth2/v2/auth&lt;/span&gt;
 &lt;span class="na"&gt;token-uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://oauth2.googleapis.com/token&lt;/span&gt;
 &lt;span class="na"&gt;user-info-uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://www.googleapis.com/oauth2/v3/userinfo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Java security configuration that activates this is identical to the OAuth2 Login example in the previous section, &lt;code&gt;http.oauth2Login()&lt;/code&gt; is all that's needed, with no additional filter registration. What changes is what happens inside the filter chain after the callback completes.&lt;/p&gt;

&lt;p&gt;In the JWT path, &lt;code&gt;SecurityContextHolderFilter&lt;/code&gt; reconstructs the &lt;code&gt;SecurityContext&lt;/code&gt; on every request by validating the bearer token. In the SSO path, the same filter reconstructs the &lt;code&gt;SecurityContext&lt;/code&gt; by reading it from the &lt;code&gt;HttpSession&lt;/code&gt;, no token validation occurs because the session already holds a fully populated &lt;code&gt;OAuth2AuthenticationToken&lt;/code&gt;. That is the meaningful delta: the persistence strategy changes from token-per-request to session-per-user, and &lt;code&gt;SecurityContextHolderFilter&lt;/code&gt; adapts accordingly through &lt;code&gt;HttpSessionSecurityContextRepository&lt;/code&gt; rather than the stateless repository wired in the JWT configuration.&lt;/p&gt;

&lt;p&gt;The older &lt;code&gt;@EnableOAuth2Sso&lt;/code&gt; annotation from the Spring Security OAuth2 legacy project achieved the same redirect-and-session behaviour, but required you to manually wire an &lt;code&gt;OAuth2ClientContext&lt;/code&gt; and build an &lt;code&gt;OAuth2ClientAuthenticationProcessingFilter&lt;/code&gt; yourself. Spring Security 5's native &lt;code&gt;oauth2Login()&lt;/code&gt; DSL absorbed all of that plumbing, which is why a single YAML block and one DSL method call are now sufficient for a fully working SSO integration against Google, GitHub, or any standards-compliant identity provider. &lt;/p&gt;

&lt;p&gt;One practical implication worth naming: because the session holds the &lt;code&gt;Authentication&lt;/code&gt;, horizontal scaling requires either sticky sessions or a shared session store like Redis. This is the trade-off you accept relative to the stateless JWT model, where any node can validate a request independently.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Comparing the Three Mechanisms: Which Hook, Which Flow, Which Config
&lt;/h2&gt;

&lt;p&gt;The three token mechanisms are not strict alternatives to one another, they solve slightly different problems, and they plug into the filter chain at different points. Knowing which slot each one occupies is the fastest way to choose between them and to diagnose failures when they occur.&lt;/p&gt;

&lt;p&gt;JWT authentication is the right choice for stateless APIs and microservices where no session should be kept server-side. Your application is the sole verifier of the token: it checks the signature, extracts claims, and populates the &lt;code&gt;SecurityContext&lt;/code&gt;, all within a single &lt;code&gt;OncePerRequestFilter&lt;/code&gt; placed before &lt;code&gt;UsernamePasswordAuthenticationFilter&lt;/code&gt;. Nothing about the user's identity is stored between requests.&lt;/p&gt;

&lt;p&gt;OAuth2 resource server configuration is appropriate when a separate authorisation server issues tokens, and your application should validate them against a well-known JWKS endpoint. Spring's built-in &lt;code&gt;BearerTokenAuthenticationFilter&lt;/code&gt; handles the extraction and delegates to the &lt;code&gt;JwtDecoder&lt;/code&gt; you configure, so you write almost no filter code yourself. The trust anchor is the authorisation server's public key, not anything your application generates.&lt;/p&gt;

&lt;p&gt;SSO via &lt;code&gt;oauth2Login&lt;/code&gt; applies when a browser-based user needs to authenticate once and have that identity recognised across multiple applications. It reuses the same &lt;code&gt;OAuth2LoginAuthenticationFilter&lt;/code&gt; that handles a standard OAuth2 login callback, but the session becomes the persistence mechanism rather than a token the client presents on each request. The identity provider, not your application, manages when that session expires.&lt;/p&gt;

&lt;p&gt;When a 401 appears, and you need to find out why, the filter chain position is almost always the first thing worth examining. In a JWT setup, a silent 401 with no accompanying error body usually means one of two things: the custom filter ran but did not write to the &lt;code&gt;SecurityContext&lt;/code&gt;, or the filter was registered at a position that places it after the authorisation check rather than before it. In an OAuth2 resource server setup, the failure is more often a misconfigured &lt;code&gt;issuer-uri&lt;/code&gt; or an expired token that the decoder rejects. In an SSO setup, a 302 redirect rather than a 401 is the more common symptom; the session wasn't found, so the user is sent back to the identity provider.&lt;/p&gt;

&lt;p&gt;The filter chain is the right mental model to carry into all three of these situations. Once you see JWT validation, OAuth2 token introspection, and SSO session lookup as separate plug-ins wired into the same ordered pipeline, the framework stops behaving like an unpredictable wall and starts behaving like a predictable sequence you can reason about step by step. Every 401 is answerable by asking: which filter was responsible for this request, did it run, and if it ran, did it write to the &lt;code&gt;SecurityContext&lt;/code&gt;? You can make that question concrete immediately by enabling &lt;code&gt;TRACE&lt;/code&gt; logging on &lt;code&gt;org.springframework.security&lt;/code&gt;, the output prints each filter in the chain as it executes, which turns a confusing status code into a visible gap in the sequence. That visibility is a direct consequence of the model: when you understand the pipeline, you know exactly where to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://github.com/hardikSinghBehl/jwt-auth-flow-spring-security" rel="noopener noreferrer"&gt;GitHub - hardikSinghBehl/jwt-auth-flow-spring-security: Java backend application using Spring-security to implement JWT based Authentication and Authorization · GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://stackoverflow.com/questions/58331184/spring-security-oauth2-add-filter-after-oauth-token-call" rel="noopener noreferrer"&gt;java - Spring security oauth2 - add filter after oauth/token call - Stack Overflow&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>backend</category>
      <category>java</category>
      <category>security</category>
      <category>springboot</category>
    </item>
    <item>
      <title>How Spring Data JPA, JPA, and Hibernate work together</title>
      <dc:creator>srinivas reddy gouru</dc:creator>
      <pubDate>Tue, 26 May 2026 17:24:05 +0000</pubDate>
      <link>https://dev.to/srinivas_gouru_d26dc31f21/how-spring-data-jpa-jpa-and-hibernate-work-together-55cn</link>
      <guid>https://dev.to/srinivas_gouru_d26dc31f21/how-spring-data-jpa-jpa-and-hibernate-work-together-55cn</guid>
      <description>&lt;h2&gt;
  
  
  The Magic Line That Raises the Right Question
&lt;/h2&gt;

&lt;p&gt;Spring Data JPA is a library that lets you query a relational database by writing a Java interface method and nothing else: no SQL, no &lt;code&gt;ResultSet&lt;/code&gt; parsing, no &lt;code&gt;PreparedStatement&lt;/code&gt; boilerplate. You declare what you want, and the framework figures out how to fetch it.&lt;/p&gt;

&lt;p&gt;Here is the moment that stops most backend engineers in their tracks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="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;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire implementation. You inject &lt;code&gt;UserRepository&lt;/code&gt; into a service, call &lt;code&gt;userRepository.findByEmail("alice@example.com")&lt;/code&gt;, and get back a populated &lt;code&gt;User&lt;/code&gt; object from the database. The method works on the first try, which is satisfying until something breaks, or until you need to understand why a query is slow, why a &lt;code&gt;LazyInitializationException&lt;/code&gt; keeps appearing, or why your transaction did not roll back the way you expected.&lt;/p&gt;

&lt;p&gt;At that point, the magic stops feeling helpful and starts feeling like a wall.&lt;/p&gt;

&lt;p&gt;The wall exists because &lt;code&gt;findByEmail&lt;/code&gt; is not a single thing. It is the visible surface of three separate layers, each with its own responsibilities, failure modes, and configuration surface. Spring Data JPA translated your method name into a query. JPA, the Java Persistence API, provided the standard contract that queries had to follow. Hibernate actually executed it against the database. Without knowing which layer owns which job, you cannot know which layer to look at when something goes wrong.&lt;/p&gt;

&lt;p&gt;To answer why that one line works, you need a clear picture of all three layers. That is what the rest of this article builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Layers, Three Jobs: The Stack in One Mental Model
&lt;/h2&gt;

&lt;p&gt;When you call &lt;code&gt;findByEmail("alice@example.com")&lt;/code&gt; on a repository interface you never implemented, what actually runs? The answer involves three distinct layers, each with a single clear job, and conflating any two of them is the source of most of the confusion engineers run into.&lt;/p&gt;

&lt;p&gt;At the bottom sits &lt;strong&gt;Hibernate&lt;/strong&gt;. Hibernate is a full ORM framework. It owns a &lt;code&gt;SessionFactory&lt;/code&gt;, manages an in-memory representation of your entities, generates SQL, and fires that SQL over JDBC to the database. When things go wrong at the database level, a bad join, an unexpected number of queries, or a lazy-loading exception, Hibernate is the layer to examine.&lt;/p&gt;

&lt;p&gt;In the middle sits &lt;strong&gt;JPA&lt;/strong&gt; (Java Persistence API). JPA is not a library you can download and run; it is a specification, a set of interfaces, annotations, and contracts that any compliant ORM must honour. It defines what &lt;code&gt;@Entity&lt;/code&gt;, &lt;code&gt;@OneToMany&lt;/code&gt;, and &lt;code&gt;EntityManager&lt;/code&gt; mean, but it ships no implementation of its own. Hibernate is Spring Boot's default JPA provider, meaning Hibernate is the concrete code that fulfils those contracts. This distinction matters because your annotations belong to JPA, while the SQL they produce belongs to Hibernate.&lt;/p&gt;

&lt;p&gt;At the top sits &lt;strong&gt;Spring Data JPA&lt;/strong&gt;. Its sole job is to eliminate boilerplate in the data-access layer. You write a repository interface; Spring generates a proxy implementation at startup that translates method names and annotations into JPA operations. Spring Data JPA does not talk to your database directly, and it is not itself an ORM. Every call it receives flows downward through JPA's &lt;code&gt;EntityManager&lt;/code&gt; and on into Hibernate, which finally issues the JDBC call. &lt;/p&gt;

&lt;p&gt;Laid out as a delegation chain, the flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your code
 → Spring Data JPA (repository proxy)
 → JPA EntityManager (standard contract)
 → Hibernate (SQL generation + JDBC)
 → Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each arrow in that chain crosses a responsibility boundary. Spring Data JPA knows about Spring conventions and method-name patterns. JPA knows about entities and the persistence context lifecycle. Hibernate knows about SQL dialects and connection pooling. None of the three layers was designed to do each other's jobs, which is exactly why each one is replaceable in theory. You could swap Hibernate for EclipseLink and keep all your JPA annotations untouched, or swap Spring Data JPA for plain &lt;code&gt;EntityManager&lt;/code&gt; calls and keep Hibernate doing exactly what it already does.&lt;/p&gt;

&lt;p&gt;Keeping this map in your head pays off the moment something goes wrong. A &lt;code&gt;LazyInitializationException&lt;/code&gt; is a Hibernate story. A &lt;code&gt;@Transactional&lt;/code&gt; annotation that seems to do nothing is a Spring story. A repository method that generates a query you didn't expect is a Spring Data JPA story, though Hibernate ultimately executes it. The next three sections go bottom-up through the stack, starting with the layer that actually touches your database.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Hibernate: The Engine That Actually Talks to the Database
&lt;/h2&gt;

&lt;p&gt;When your Spring application saves an entity and an SQL &lt;code&gt;INSERT&lt;/code&gt; appears in the logs, Hibernate wrote that statement. It is an Object-Relational Mapping framework whose single job is bridging the gap between Java objects and relational tables, translating method calls and object state into the SQL your database actually understands, so you don't have to hand-write every query for routine CRUD operations. &lt;/p&gt;

&lt;p&gt;At the centre of Hibernate's runtime is the &lt;code&gt;SessionFactory&lt;/code&gt;, a heavyweight, thread-safe object built once at application startup. From that factory, Hibernate creates per-request &lt;code&gt;Session&lt;/code&gt; instances (or, in the JPA vocabulary you'll see more often in Spring code, &lt;code&gt;EntityManager&lt;/code&gt; instances). Those per-request objects are not thread-safe, so Spring handles their lifecycle carefully. The &lt;code&gt;EntityManager&lt;/code&gt; you inject with &lt;code&gt;@PersistenceContext&lt;/code&gt; is actually a thread-bound proxy that delegates to whichever transactional &lt;code&gt;EntityManager&lt;/code&gt; is currently active for that request, falling back to a freshly created one if no transaction is in progress. That indirection is invisible in normal usage but matters when you start debugging concurrency issues.&lt;/p&gt;

&lt;p&gt;Several behaviours that surface as runtime surprises are owned entirely by Hibernate, not by the layers above it. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dirty checking&lt;/strong&gt; is one of the most useful and least understood. Inside an active transaction, Hibernate holds a snapshot of every entity it has loaded. When the transaction commits, it compares current field values against those snapshots and issues &lt;code&gt;UPDATE&lt;/code&gt; statements automatically for anything that changed, even if you never called &lt;code&gt;save()&lt;/code&gt;. This is a feature, but it confuses engineers who expect no SQL to run unless they explicitly persist something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First-level cache&lt;/strong&gt; (the session cache) means that within a single &lt;code&gt;EntityManager&lt;/code&gt; lifetime, calling &lt;code&gt;find(User.class, 1L)&lt;/code&gt; twice returns the same Java object from memory on the second call, with no second database round-trip. This cache is scoped to the session, not the application, so it disappears when the session closes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lazy loading&lt;/strong&gt; lets Hibernate defer fetching associated collections until your code actually accesses them. A &lt;code&gt;User&lt;/code&gt; with a &lt;code&gt;@OneToMany&lt;/code&gt; list of &lt;code&gt;Orders&lt;/code&gt; won't load those orders until you call &lt;code&gt;user.getOrders()&lt;/code&gt;. The downside is the N+1 problem: if you load 50 users in a list and then touch &lt;code&gt;.getOrders()&lt;/code&gt; for each one inside a loop, Hibernate fires one query to fetch the users and then 50 separate queries to fetch each user's orders. The fix usually involves a &lt;code&gt;JOIN FETCH&lt;/code&gt; in your query or switching the fetch type, but the first step is recognising that Hibernate is what's generating those 50 extra statements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQL dialect translation&lt;/strong&gt; means Hibernate handles the differences between database engines. You configure &lt;code&gt;spring.jpa.database-platform&lt;/code&gt; to point at a dialect class, and Hibernate adapts its generated SQL to match the syntax of PostgreSQL, MySQL, Oracle, or whatever you're running.&lt;/p&gt;

&lt;p&gt;All of these behaviours, dirty checking, caching, lazy loading, dialect generation, live at the Hibernate layer. When something breaks, the explanation usually lives there too. The layer above it, JPA, doesn't implement any of this; it just defines the contract that Hibernate must honour.&lt;/p&gt;

&lt;h2&gt;
  
  
  JPA: The Standard Contract Every ORM Must Honour
&lt;/h2&gt;

&lt;p&gt;The previous section covered what Hibernate actually does at runtime. But if you look at a typical Spring Boot entity class, you'll notice the imports don't say &lt;code&gt;org.hibernate.*&lt;/code&gt;. They say &lt;code&gt;jakarta.persistence.*&lt;/code&gt; (or &lt;code&gt;javax.persistence.*&lt;/code&gt; on older versions). That's JPA, and understanding why it's there clarifies the whole stack.&lt;/p&gt;

&lt;p&gt;JPA, the Jakarta Persistence API, is a specification, not a library you run. It defines a set of interfaces, annotations, and rules that any compliant ORM must implement. The annotations you use every day, &lt;code&gt;@Entity&lt;/code&gt;, &lt;code&gt;@Id&lt;/code&gt;, &lt;code&gt;@OneToMany&lt;/code&gt;, &lt;code&gt;@Column&lt;/code&gt;, are all defined by JPA. The central API for interacting with the persistence context, &lt;code&gt;EntityManager&lt;/code&gt;, is also defined by JPA, with methods like &lt;code&gt;persist&lt;/code&gt;, &lt;code&gt;find&lt;/code&gt;, &lt;code&gt;merge&lt;/code&gt;, and &lt;code&gt;remove&lt;/code&gt;. Hibernate is simply the most popular implementation of that contract.&lt;/p&gt;

&lt;p&gt;This separation matters practically. Because your application code targets JPA interfaces rather than Hibernate-specific classes, you could swap Hibernate for EclipseLink or OpenJPA and recompile without touching your entity or service code. In practice, most teams never make that swap, but portability isn't the only benefit. The bigger win is that JPA gives the whole Java ecosystem a shared vocabulary. Documentation, Stack Overflow answers, and framework libraries all speak JPA, even when each is backed by a different provider.&lt;/p&gt;

&lt;p&gt;JPA also introduced JPQL, the Java Persistence Query Language, which lets you query against entity class names and field names rather than table and column names. A JPQL query looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;TypedQuery&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;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
 &lt;span class="s"&gt;"SELECT o FROM Order o WHERE o.customer.email =:email"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Order&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;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"alice@example.com"&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;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getResultList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that to raw JDBC, where you would write a SQL string against &lt;code&gt;orders.customer_email&lt;/code&gt;, manually open a &lt;code&gt;ResultSet&lt;/code&gt;, and map each column back to a field by hand. JPQL keeps queries at the object level, so renaming a Java field and its corresponding column mapping stays in one place rather than scattered across SQL strings throughout the codebase.&lt;/p&gt;

&lt;p&gt;The honest limitation of plain JPA is that it still requires you to wire up an &lt;code&gt;EntityManagerFactory&lt;/code&gt;, manage &lt;code&gt;EntityManager&lt;/code&gt; lifecycles, write those &lt;code&gt;createQuery&lt;/code&gt; calls, and handle transactions explicitly. For a small application that's manageable. For a service with thirty entity types and hundreds of queries, it becomes a lot of repeated structural code. That gap is exactly what Spring Data JPA was designed to close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring Data JPA: The Boilerplate Eliminator on Top
&lt;/h2&gt;

&lt;p&gt;With the JPA specification and Hibernate's implementation both accounted for, the question that opened this article is still hanging: where exactly does &lt;code&gt;findByEmail()&lt;/code&gt; come from? You never wrote a SQL query, never touched an &lt;code&gt;EntityManager&lt;/code&gt;, never defined a concrete class. The answer lives entirely in Spring Data JPA's repository abstraction.&lt;/p&gt;

&lt;p&gt;When your application starts up, Spring Data JPA scans for interfaces that extend &lt;code&gt;JpaRepository&lt;/code&gt; (or one of its parent interfaces) and generates concrete implementations on the fly. No DAO class needs to be written, because Spring creates a proxy object that wires in the actual behaviour at startup, before any request arrives. That proxy is what gets injected into your service layer when you use &lt;code&gt;@Autowired&lt;/code&gt; or constructor injection.&lt;/p&gt;

&lt;p&gt;The method-name translation is the part that feels most like magic. Spring Data JPA parses the method signature using reflection, breaks it down by the keywords it recognises (&lt;code&gt;findBy&lt;/code&gt;, &lt;code&gt;And&lt;/code&gt;, &lt;code&gt;Or&lt;/code&gt;, &lt;code&gt;OrderBy&lt;/code&gt;, &lt;code&gt;Between&lt;/code&gt;, and so on), and constructs a JPQL query to match. A method named &lt;code&gt;findByLastNameAndAgeGreaterThan&lt;/code&gt; becomes something like &lt;code&gt;SELECT u FROM User u WHERE u.lastName =:lastName AND u.age &amp;gt;:age&lt;/code&gt;, assembled at startup rather than at call time. If you make a typo in the method name that produces an unresolvable expression, the application will fail to start, not fail at 2 AM when that code path is first hit in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="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;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByLastName&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;lastName&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
 &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
 &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByAgeGreaterThanOrderByLastNameAsc&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;age&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire file. Spring Data JPA does the rest.&lt;/p&gt;

&lt;p&gt;When one of these methods is called at runtime, the proxy delegates to a &lt;code&gt;SimpleJpaRepository&lt;/code&gt; implementation under the hood, which in turn uses a JPA &lt;code&gt;EntityManager&lt;/code&gt; to execute the translated query. That &lt;code&gt;EntityManager&lt;/code&gt; call flows down to Hibernate, which generates the SQL and sends it to the database. The call chain traverses all three layers: your repository interface, the JPA &lt;code&gt;EntityManager&lt;/code&gt;, and Hibernate's SQL engine.&lt;/p&gt;

&lt;p&gt;Spring Data JPA is part of the broader Spring Data umbrella project, which applies the same repository abstraction pattern to MongoDB, Redis, Cassandra, and other stores. Switching from a relational database to MongoDB doesn't require learning an entirely different programming model. You extend a MongoDB-specific repository interface, and the query derivation works the same way. That consistency is the deeper goal of Spring Data JPA's design.&lt;/p&gt;

&lt;p&gt;Derived query methods cover a large surface area, but they are not the only option. For anything complex, &lt;code&gt;@Query&lt;/code&gt; lets you write JPQL (or even native SQL with &lt;code&gt;nativeQuery = true&lt;/code&gt;) directly on the method. The proxy mechanism stays in place; only the query source changes. This means you can stay in the repository interface without writing any implementation code, even for queries that method-name derivation cannot express cleanly.&lt;/p&gt;

&lt;p&gt;Knowing which layer owns which behaviour is immediately useful when things go wrong.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  When the Magic Breaks: Debugging Across the Three Layers
&lt;/h2&gt;

&lt;p&gt;Each failure mode has a home layer, and pointing your debugger at the wrong one wastes time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;LazyInitializationException&lt;/code&gt;, Hibernate's session is closed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This exception means Hibernate tried to load a lazy association, looked for an open persistence context, and found none. It is a Hibernate-layer error about session lifecycle, not a Spring Data JPA bug. The usual cause is accessing a &lt;code&gt;@OneToMany&lt;/code&gt; collection after the repository method has returned and the &lt;code&gt;EntityManager&lt;/code&gt; has closed. Fix it at the layer that owns sessions: either annotate the calling service method with &lt;code&gt;@Transactional&lt;/code&gt; so the persistence context stays open while you traverse the association, or switch the fetch type to eager for relationships you always need. A third option is a JPQL &lt;code&gt;JOIN FETCH&lt;/code&gt; or &lt;code&gt;@EntityGraph&lt;/code&gt; on the repository method itself, which tells Hibernate to load the association in the initial query rather than deferring it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unexpected query count, Hibernate's lazy-loading strategy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your application issues far more SQL statements than you expect, the N+1 problem is the likely culprit. Hibernate's default for &lt;code&gt;@OneToMany&lt;/code&gt; is &lt;code&gt;FetchType.LAZY&lt;/code&gt;, so fetching ten authors and iterating their posts triggers one query for the author list and ten more for the post collections. You won't see this in your repository interface; you have to look at what Hibernate is generating. Enable SQL logging with these two lines 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.jpa.show-sql&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;logging.level.org.hibernate.SQL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;DEBUG&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you can see the queries, the fix is at the Hibernate or JPA layer, not the Spring Data layer. A &lt;code&gt;JOIN FETCH&lt;/code&gt; in a &lt;code&gt;@Query&lt;/code&gt; annotation collapses the N+1 into a single join. &lt;code&gt;@EntityGraph&lt;/code&gt; on the repository method achieves the same result declaratively. For cases where you prefer to keep lazy loading but still want fewer round trips, setting &lt;code&gt;spring.jpa.properties.hibernate.default_batch_fetch_size=10&lt;/code&gt; tells Hibernate to load related collections in batches with an &lt;code&gt;IN&lt;/code&gt; clause rather than one query per row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transaction rollback surprises, Spring's &lt;code&gt;@Transactional&lt;/code&gt; infrastructure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a transaction does not behave the way you expect, changes not persisting, rollbacks firing on the wrong exception, or operations on a second data source not participating in the transaction, the problem lives at the Spring layer. &lt;code&gt;@Transactional&lt;/code&gt; is managed entirely by Spring's AOP proxy, and its default behaviour is to roll back only on unchecked exceptions. If your application has multiple data sources, Spring applies &lt;code&gt;@Transactional&lt;/code&gt; to the primary one by default; the secondary data source participates only if you configure a &lt;code&gt;JtaTransactionManager&lt;/code&gt; or explicitly name the transaction manager in the annotation. The fix is a Spring configuration change, not a Hibernate tuning exercise.&lt;/p&gt;

&lt;p&gt;The mental model from earlier sections makes all three of these straightforward to triage: session lifecycle is Hibernate, query shape is Hibernate, transaction boundaries are Spring. When you see a stack trace or unexpected behaviour, ask which layer owns that concept, then inspect or configure exactly that layer. Turning on SQL logging in development is a good habit regardless; the raw queries Hibernate sends are the single most informative signal available, and most performance surprises reveal themselves within a few minutes of reading them.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>java</category>
      <category>springboot</category>
    </item>
  </channel>
</rss>
