<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Alexander Obregon's Substack: Java and JVM]]></title><description><![CDATA[This is where all my Java content lives. Posts on JVM-based stuff like Kotlin, Scala, Groovy, and other related topics will show up here too.]]></description><link>https://alexanderobregon.substack.com/s/java</link><image><url>https://substackcdn.com/image/fetch/$s_!4Mj9!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Falexanderobregon.substack.com%2Fimg%2Fsubstack.png</url><title>Alexander Obregon&apos;s Substack: Java and JVM</title><link>https://alexanderobregon.substack.com/s/java</link></image><generator>Substack</generator><lastBuildDate>Wed, 22 Apr 2026 02:36:20 GMT</lastBuildDate><atom:link href="https://alexanderobregon.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Alexander Obregon]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[alexanderobregon@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[alexanderobregon@substack.com]]></itunes:email><itunes:name><![CDATA[Alexander Obregon]]></itunes:name></itunes:owner><itunes:author><![CDATA[Alexander Obregon]]></itunes:author><googleplay:owner><![CDATA[alexanderobregon@substack.com]]></googleplay:owner><googleplay:email><![CDATA[alexanderobregon@substack.com]]></googleplay:email><googleplay:author><![CDATA[Alexander Obregon]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Booth's Algorithm for Finding the Lexicographically Smallest Rotation in Java]]></title><description><![CDATA[Circular strings come up in cyclic IDs, necklace matching, polygon normalization, and any case where the end loops back to the start.]]></description><link>https://alexanderobregon.substack.com/p/booths-algorithm-for-finding-the</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/booths-algorithm-for-finding-the</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Fri, 17 Apr 2026 17:11:41 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!nItX!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!nItX!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!nItX!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!nItX!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!nItX!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!nItX!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!nItX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/bda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!nItX!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!nItX!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!nItX!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!nItX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbda13076-b742-46f5-9c61-a2b744ff1b6a_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Circular strings come up in cyclic IDs, necklace matching, polygon normalization, and any case where the end loops back to the start. With that kind of string, several written forms can still represent the same circle. <code>caba</code>, <code>abac</code>, <code>baca</code>, and <code>acab</code> are all rotations of one another, but they refer to the same circular order of characters. Booth&#8217;s algorithm gives that circle one fixed written form by finding the lexicographically smallest rotation in linear time. It takes the failure table idea from Knuth-Morris-Pratt string matching, then lets mismatches rule out weaker starting positions without rebuilding and comparing every possible rotation.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>What the Problem Is Really Asking</h3><p>Before Booth&#8217;s algorithm can make sense, the string has to be viewed in the right way. Nothing is being rearranged freely. The characters stay in the same circular order, and the question becomes which written version of that circle should count as the smallest in lexicographic order. That difference changes the whole problem. Booth&#8217;s algorithm is built for circular strings, so the first step is getting precise about what a rotation really is and why checking every starting position turns costly fast.</p><h4>Rotation Means the Same Characters With a Different Start</h4><p>You can think of the string as wrapped into a ring. Reading from one index gives one written form, and reading from a different index gives a different written form, but the circular order never changes. With <code>caba</code>, the rotations are <code>caba</code>, <code>abac</code>, <code>baca</code>, and <code>acab</code>. All four strings represent the same cycle of characters. The only thing that changes is where the read begins.</p><p>That detail keeps the problem grounded. Rotation is not sorting the letters. Rotation is not taking a small substring and calling it done. Rotation is not moving a character to a new spot while the rest stays still. Every character keeps the same neighbors in the circle. Only the starting point changes.</p><p>This Java helper makes that visible:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5d902831-fb27-40b7-b267-85ef3b4db23e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static String rotationAt(String s, int start) {
    return s.substring(start) + s.substring(0, start);
}

public static void main(String[] args) {
    String s = "caba";

    System.out.println(rotationAt(s, 0)); // caba
    System.out.println(rotationAt(s, 1)); // abac
    System.out.println(rotationAt(s, 2)); // baca
    System.out.println(rotationAt(s, 3)); // acab
}</code></pre></div><p>Nothing in that method changes character values or relative order. It takes the suffix from <code>start</code> to the end, then places the earlier prefix after it. Reading begins at a new spot, but the ring itself stays the same.</p><p>Lexicographic order then decides which rotation comes first. Java&#8217;s <code>String.compareTo</code> reads from left to right and stops at the first position where the two strings differ. The string with the smaller character at that position comes first. With the rotations of <code>caba</code>, <code>abac</code> beats <code>acab</code> because the first characters match as <code>a</code>, then <code>b</code> comes before <code>c</code> at the next position. <code>abac</code> also beats <code>baca</code> and <code>caba</code> right away because <code>a</code> comes before <code>b</code> and <code>c</code>.</p><p>Small checks can make that rule easier to see:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;620e6622-11c5-46b7-a19a-aa7fce90bee9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static void main(String[] args) {
    String left = "abac";
    String right = "acab";

    System.out.println(left.compareTo(right) &lt; 0); // true
}</code></pre></div><p>That result means <code>abac</code> is lexicographically smaller than <code>acab</code>. Later characters do not need to be read after the first mismatch settles the comparison.</p><p>Repeated structure inside a string adds a useful detail. Some inputs can produce the same rotation string from more than one starting index. <code>abab</code> is a good case. Starting at index <code>0</code> gives <code>abab</code>, and starting at index <code>2</code> also gives <code>abab</code>. As a string result, the least rotation is still well defined. As a starting index, more than one index can produce that same least written form.</p><p>Beginners sometimes read this problem as though the whole goal were finding the smallest character and starting there. That idea is not quite enough by itself. If the smallest character appears in several positions, the rest of the rotation still has to be compared. Take <code>baaa</code>. Starting at index <code>1</code>, <code>2</code>, or <code>3</code> gives <code>aaab</code>, <code>aaba</code>, and <code>abaa</code>. All three begin with <code>a</code>, but only <code>aaab</code> is the least rotation. Full lexicographic comparison still decides the winner.</p><h4>Why Checking Every Shift Is a Bad Deal</h4><p>Brute-force rotation search is a reasonable first pass. You build every rotation, compare each candidate against the current best value, and keep the smallest one. That route states the problem in direct terms, which makes it useful for getting started. Trouble shows up in the repeated string comparisons hiding inside it.</p><p>A basic Java version looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8c7e01b7-9988-41b6-8a29-e857c90949cf&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static String leastRotationBruteForce(String s) {
    if (s.isEmpty()) {
        return s;
    }

    String best = rotationAt(s, 0);

    for (int start = 1; start &lt; s.length(); start++) {
        String candidate = rotationAt(s, start);
        if (candidate.compareTo(best) &lt; 0) {
            best = candidate;
        }
    }

    return best;
}</code></pre></div><p>That method is easy to read. For a short string, it is perfectly fine. If the input length is <code>n</code>, there are <code>n</code> candidate rotations. Each comparison can read up to <code>n</code> characters before it finds the first mismatch or reaches the end. Building each candidate rotation also touches the characters again. Put that all across the full scan, and the total cost grows roughly like <code>n&#178;</code>.</p><p>Repeated prefixes are what make the cost feel heavier than it first sounds. With a string such as <code>aaaaab</code>, several rotations begin with long runs of <code>a</code>. Comparing two candidates means reading through that repeated prefix until a later character finally breaks the tie. Then the next candidate can force a very similar read again. Large parts of earlier comparisons get repeated instead of reused.</p><p>That repeated effort is the real problem, not the fact that the code loops through starting indexes. If two candidate rotations share a long beginning, the brute-force method has no memory of what earlier failed comparisons already proved. It simply reads through the same territory again. Long repeated sections inside the input make that waste more noticeable. Space can stay fairly low if candidates are built one at a time, like the method above does. Storing every rotation at once would take more memory, but avoiding that still does not fix the repeated comparison cost. The heavy part comes from checking all candidate starts and rereading long common prefixes across those comparisons.</p><p>This is where the need for Booth&#8217;s algorithm starts to come into view. Checking every rotation is easy to explain, but that route keeps treating nearby starting positions almost like unrelated cases. A stronger string method keeps information from failed matches so those losing regions do not need to be tested from scratch every time.</p><h3>How Booth&#8217;s Algorithm Moves the Start Point</h3><p>Booth&#8217;s algorithm gets its speed from the way it treats comparisons as reusable information. Instead of treating every candidate start as a fresh attempt, it keeps a current best start, scans forward through a doubled view of the string, and records how far the current candidate had matched before a mismatch happened. That saved match length changes what the algorithm does next. A later mismatch can move the start forward by more than a single position, which is the whole reason the method stays linear.</p><h4>Double the String So Rotation Becomes a Slice</h4><p>Circular indexing can be turned into ordinary substring logic by writing the string twice in a row. If the original string is <code>s</code>, then <code>s + s</code> contains every rotation of <code>s</code> as a contiguous run of length <code>n</code>, where <code>n</code> is the original string length. That idea removes the need to wrap back to index <code>0</code> during each comparison, which keeps the scan far more readable.</p><p>Take <code>caba</code>. Writing it twice gives <code>cabacaba</code>, starting at index <code>0</code> and reading <code>4</code> characters gives <code>caba</code>. Starting at index <code>1</code> and reading <code>4</code> characters gives <code>abac</code>. Starting at index <code>2</code> gives <code>baca</code>, and starting at index <code>3</code> gives <code>acab</code>. Every possible rotation now lives inside a linear string, which means the algorithm can walk left to right without special-case wraparound rules at each step.</p><p>Let&#8217;s see that with a short helper:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5bee09c4-179a-42cb-8ecd-f20308ce34a4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static String rotationWindow(String s, int start) {
    String doubled = s + s;
    return doubled.substring(start, start + s.length());
}

public static void main(String[] args) {
    String s = "caba";

    System.out.println(rotationWindow(s, 0)); // caba
    System.out.println(rotationWindow(s, 1)); // abac
    System.out.println(rotationWindow(s, 2)); // baca
    System.out.println(rotationWindow(s, 3)); // acab
}</code></pre></div><p>That helper is not Booth&#8217;s algorithm by itself. Its only job is to make the indexing idea tangible. Rotation stops feeling circular as soon as the string is doubled, because every candidate can now be treated as a normal slice inside a longer string.</p><p>That trick also explains why Booth&#8217;s algorithm tracks a start index in the doubled string rather than building new strings again and again. If a better start is found, the algorithm does not need to create a new rotated value just to continue. It can keep scanning inside the doubled string and compare characters by index. Memory use stays lower that way, and the scan logic stays centered on positions rather than on newly built string objects.</p><p>Indices inside the doubled string still map back to the original string. If the best start ends up at <code>5</code> in a doubled view of a string of length <code>4</code>, then the matching start in the original string is <code>5 % 4</code>, which is <code>1</code>. That remainder step is why the final answer can be returned as a position in the original input.</p><p>This index helper makes that link a bit more visual:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;17117997-ccde-47ca-8b4d-9fe69765aaa7&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static int originalIndex(int doubledIndex, int n) {
    return doubledIndex % n;
}

public static void main(String[] args) {
    System.out.println(originalIndex(5, 4)); // 1
}</code></pre></div><p>Nothing deep is happening in that method, but it helps keep the later scan grounded. Booth&#8217;s algorithm may move through a string of length <code>2n</code>, yet the answer still belongs to the original string of length <code>n</code>.</p><h4>The Failure Table Stores What the Match Already Proved</h4><p>Much of the saved time comes from the failure table. Booth&#8217;s algorithm borrows the general fallback idea from Knuth-Morris-Pratt preprocessing. During the scan, characters may match for a while before a mismatch appears. Instead of discarding that partial match and starting from zero, the algorithm keeps a table that records how far the match had gone and where a shorter fallback comparison should resume.</p><p>Picture a current candidate start at index <code>start</code>. As the scan moves forward to index <code>j</code>, the algorithm compares <code>doubled.charAt(j)</code> against a character inside the candidate rotation. If they match, the current matched length grows. If they do not, the failure table says which shorter matched prefix is still worth testing before giving up on the current prefix length completely.</p><p>That table is not storing full rotations. It stores matched-prefix lengths relative to the current candidate. Value <code>-1</code> means no shorter fallback exists at that relative position. Nonnegative values mean a shorter matched prefix can still be reused. This is what stops the algorithm from rereading the full matched portion from the beginning after every failed comparison.</p><p>Let&#8217;s see a small view of the initialization:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5fc06635-0b9b-4efe-b476-0f2833e1ed27&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.Arrays;

public static void main(String[] args) {
    String doubled = "cabacaba";
    int[] failure = new int[doubled.length()];
    Arrays.fill(failure, -1);

    System.out.println(Arrays.toString(failure));
}</code></pre></div><p>Filling the table with <code>-1</code> means fallback links do not exist yet. As the scan advances, positions that do have reusable prefix information get updated with nonnegative values. Booth&#8217;s algorithm then walks those values backward during mismatches, just as KMP-style preprocessing walks prefix links instead of starting the comparison from the front again.</p><p>Two moving values carry most of the scan. <code>start</code> marks the current best candidate rotation, and a relative match index derived from the failure table tells the algorithm how far the candidate had matched before trouble appeared. If the next character keeps the match alive, the new failure entry records that longer prefix. If the next character breaks the match, fallback values guide the scan through shorter prefixes that may still match.</p><p>That reuse is where the time savings come from because the brute-force comparison treats each failed long prefix as lost effort. Booth&#8217;s algorithm treats that same long prefix as information worth saving. If several candidate starts share the same beginning, the failure table stops the scan from proving the same prefix relationship from scratch every time.</p><h4>Failed Matches Can Prove a Better Start Exists</h4><p>Mismatches do more than stop a comparison. In Booth&#8217;s algorithm, a mismatch can prove that the current candidate start no longer deserves to stay in front. That part is what gives the method its character. The scan is not just asking did these two characters differ. It is also asking what that difference says about the best possible start from this point onward.</p><p>Let&#8217;s say the current candidate start is <code>start</code>, the scan is at position <code>j</code>, and the current matched prefix length is tied to <code>i</code>. If <code>doubled.charAt(j)</code> is smaller than the character it just failed against inside the current candidate, then the existing candidate cannot stay best. A lexicographically smaller character has appeared at the exact place where the old candidate lost. That gives the algorithm permission to move <code>start</code> forward.</p><p>Now let&#8217;s look at the fragment where the mismatch logic shows up:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8b7a6b32-9d17-4adb-923a-0c651163c198&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">while (i != -1 &amp;&amp; doubled.charAt(j) != doubled.charAt(start + i + 1)) {
    if (doubled.charAt(j) &lt; doubled.charAt(start + i + 1)) {
        start = j - i - 1;
    }
    i = failure[i];
}</code></pre></div><p>That update to <code>start</code> is not arbitrary. <code>j - i - 1</code> points to the position where the newly better candidate begins, based on how far the earlier match had already gone. In other words, the scan is taking the matched length into account when it decides how far the new start should jump. Nearby losing starts get ruled out in a single move.</p><p>There is also a shorter mismatch case where no fallback prefix remains. Then the algorithm compares the scan character directly against the first character of the current candidate. If the scan character is smaller, the start moves straight to <code>j</code>.</p><p>That case looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;181a0af5-bc06-4b04-a15d-6a95600d5254&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">if (doubled.charAt(j) != doubled.charAt(start + i + 1)) {
    if (doubled.charAt(j) &lt; doubled.charAt(start)) {
        start = j;
    }
    failure[j - start] = -1;
} else {
    failure[j - start] = i + 1;
}</code></pre></div><p>Those two cases are the heart of the method. A mismatch after a long partial match can jump the start by more than one index. A mismatch with no shorter fallback can still replace the current start outright if the new character is smaller. Either way, failed comparisons are not wasted. They narrow the field of candidate starts.</p><p>Walk through <code>caba</code> and the movement becomes easier to follow. The scan begins with <code>start = 0</code>, so the current candidate rotation begins with <code>c</code>. Very early, the scan sees <code>a</code>. That <code>a</code> is smaller than <code>c</code>, which means the candidate beginning with <code>c</code> cannot stay best. The start moves to the position of that <code>a</code>. After that, later characters test the new candidate against nearby alternatives. By the time the scan finishes, the best start is <code>1</code>, which gives the least rotation <code>abac</code>.</p><p>Repeated letters make the same rule more valuable. With a string such as <code>aaaaab</code>, the scan can match a long run before the deciding character appears. When that decision finally comes, the mismatch does more than say these two candidate views differ. It also rules out several nearby starts that would lose for the same reason. That is why the algorithm does not sink into quadratic behavior on strings with long repeated prefixes.</p><p>Time cost stays <code>O(n)</code> because the scan moves through the doubled string with bounded fallback steps, and the well-known comparison bound is at most about <code>3n</code> character comparisons. Space cost for the table stays <code>O(n)</code>.</p><h4>Java Implementation</h4><p>Java can express the full scan with only core library types. <code>String</code>, <code>charAt</code>, <code>substring</code>, arrays, and <code>Arrays.fill</code> are enough. The version below returns both the least-rotation start index and the final rotated string.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;919c09de-372c-48bb-8983-428858016b34&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.Arrays;

public final class BoothRotation {
    private BoothRotation() {
    }

    public static int leastRotationIndex(String s) {
        int n = s.length();
        if (n == 0) {
            return 0;
        }

        String doubled = s + s;
        int[] failure = new int[doubled.length()];
        Arrays.fill(failure, -1);

        int start = 0;

        for (int j = 1; j &lt; doubled.length(); j++) {
            int i = failure[j - start - 1];

            while (i != -1 &amp;&amp; doubled.charAt(j) != doubled.charAt(start + i + 1)) {
                if (doubled.charAt(j) &lt; doubled.charAt(start + i + 1)) {
                    start = j - i - 1;
                }
                i = failure[i];
            }

            if (doubled.charAt(j) != doubled.charAt(start + i + 1)) {
                if (doubled.charAt(j) &lt; doubled.charAt(start)) {
                    start = j;
                }
                failure[j - start] = -1;
            } else {
                failure[j - start] = i + 1;
            }
        }

        return start % n;
    }

    public static String leastRotation(String s) {
        if (s.isEmpty()) {
            return s;
        }

        int start = leastRotationIndex(s);
        return s.substring(start) + s.substring(0, start);
    }

    public static void main(String[] args) {
        String s = "caba";

        System.out.println(leastRotationIndex(s)); // 1
        System.out.println(leastRotation(s));      // abac
    }
}</code></pre></div><p>Several lines in that class carry most of the meaning. <code>String doubled = s + s;</code> turns the circular problem into a linear scan. <code>Arrays.fill(failure, -1);</code> sets the fallback table to a state where no reusable shorter prefix exists yet. <code>start</code> tracks the current best candidate. <code>j</code> scans forward through the doubled string. <code>i</code> follows failure links backward when a mismatch appears after a partial match.</p><p>Reading <code>leastRotationIndex</code> from top to bottom, the method starts by handling the empty string, then builds the doubled view, then sets up the failure table, and then enters the main scan. The outer <code>for</code> loop pushes <code>j</code> across the doubled string. The inner <code>while</code> loop handles repeated fallback after mismatches. That loop is what keeps the method linear. It does not restart a full comparison from the front each time. It reuses prefix information already recorded in <code>failure</code>.</p><p><code>leastRotation</code> is short because all of the real logic lives in the index method. After the least start is known, building the final answer is just a suffix and prefix join based on that index. Keeping those responsibilities separate is useful for teaching and testing. If a reader wants to verify the scan itself, the index method can be checked alone. If a reader wants the final rotated string, the helper method can build it directly.</p><p>Time cost for the full implementation is <code>O(n)</code>. Space cost is <code>O(n)</code> because of the doubled string and the failure table. Those costs are the reason Booth&#8217;s algorithm keeps coming up in string algorithm study. It takes a problem that invites repeated full comparisons and turns it into a scan where failed matches help decide what should happen next instead of becoming lost effort.</p><h3>Conclusion</h3><p>Booth&#8217;s algorithm turns a circular string problem into a left-to-right scan where the doubled string handles wraparound, the failure table carries forward earlier match progress, and a mismatch can move the candidate start ahead instead of forcing the search to begin again. That is what gives the method its <code>O(n)</code> time behavior. Rather than building every rotation and comparing them all in full, it keeps narrowing the search with information each failed comparison already revealed.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/String.html">Java </a></em><code>String</code><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/String.html"> Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Arrays.html">Java </a></em><code>Arrays</code><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Arrays.html"> Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/String.html#compareTo%28java.lang.String%29">Java </a></em><code>String.compareTo</code><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/String.html#compareTo%28java.lang.String%29"> Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/String.html#substring%28int%29">Java </a></em><code>substring</code><em><a href="https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/String.html#substring%28int%29"> Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/String.html#charAt%28int%29">Java </a></em><code>charAt</code><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/String.html#charAt%28int%29"> Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Bloom Filters for Blocking Repeated Cache Misses in Spring Boot]]></title><description><![CDATA[Services can reject an absent id before a miss turns into yet another trip to Redis or a database.]]></description><link>https://alexanderobregon.substack.com/p/bloom-filters-for-blocking-repeated</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/bloom-filters-for-blocking-repeated</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Wed, 15 Apr 2026 17:08:39 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/4afe9ad9-1497-43a6-9e30-41c0e061c8c4_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!HeZR!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!HeZR!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!HeZR!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!HeZR!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!HeZR!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!HeZR!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!HeZR!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!HeZR!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!HeZR!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!HeZR!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1f065711-8216-4d8e-8317-7d9a75721e48_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://spring.io/projects/spring-boot">Image Source</a></figcaption></figure></div><p>Services can reject an absent id before a miss turns into yet another trip to Redis or a database. That helps most with ids that do not exist at all. Without a front gate, the same missing row can miss Redis, miss the database, return not found, then run through that full cycle again on the next request. The filter keeps a compressed record of inserted ids, so definite negatives can be rejected right away while probable positives move into the usual lookup flow. False positives still happen for a small share of absent ids, but memory stays compact and the volume of repeated misses drops fast.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>What a Bloom Filter Stores</h3><p>Membership is the whole job here. Rather than keeping full values in memory, a Bloom filter records only which bit positions were turned on after hashing. That choice is why the structure stays small. Row ids, usernames, slugs, or lookup tokens do not live in the filter as complete values. What remains is a compact bit-based representation that can answer a narrow question very quickly. Was this value ever added to the filter?</p><h4>The Bit Array</h4><p>Inside the filter lives a bit array with <code>m</code> positions, and every position begins at <code>0</code>. Inserting a value does not place that value into the array. Hash results point to several positions, and those positions are flipped from <code>0</code> to <code>1</code>. Later, a lookup repeats the same hash calculations and checks those same positions. Find any <code>0</code> and the answer is definite no. Find all <code>1</code> values and the filter can only say probably present.</p><p>Nothing in that array looks like a saved row id. No slot says account <code>9183</code> lives here, and no bucket holds a full primary key. Memory stays tight because the filter never keeps the original item. It keeps only the bit positions touched during insertion.</p><p>That storage choice is what makes Bloom filters so compact. Each bit carries almost no meaning by itself. Value comes from the collection of positions tied to a hashed item. Insert a value, mark several positions. Check that value later, revisit those same positions. That is enough to reject values that were never added without paying the memory cost of a normal set or map.</p><p>Take this Java example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;0e8c87a0-286e-4247-9f46-989b6e413b61&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.BitSet;

public final class BitArrayBloom {
    private final BitSet bits;
    private final int size;

    public BitArrayBloom(int size) {
        this.size = size;
        this.bits = new BitSet(size);
    }

    public void mark(int index) {
        bits.set(index);
    }

    public boolean isMarked(int index) {
        return bits.get(index);
    }

    public int size() {
        return size;
    }
}</code></pre></div><p>Nothing in that class stores an id directly. The class stores only bit positions, which is the first part of the structure.</p><p>Now connect that storage area to an inserted value. Say a row id hashes to three indexes such as <code>9</code>, <code>31</code>, and <code>88</code>. Those positions are turned on. Checking that same row id later means reading those same positions again. If position <code>31</code> is still <code>0</code>, the row id was never inserted. If all three positions are <code>1</code>, the filter has enough evidence to say probably present.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;b43aa207-57c8-4449-9dd8-f107cca53fe3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public final class BitArrayDemo {
    public static void main(String[] args) {
        BitArrayBloom bloom = new BitArrayBloom(128);

        int[] positionsForUserId = {9, 31, 88};
        for (int position : positionsForUserId) {
            bloom.mark(position);
        }

        System.out.println(bloom.isMarked(9));
        System.out.println(bloom.isMarked(31));
        System.out.println(bloom.isMarked(88));
        System.out.println(bloom.isMarked(77));
    }
}</code></pre></div><p>That example leaves hashing out on purpose so the storage side stays in focus. Separating the bit array from the hash step makes the physical storage rule much more readable. Positions are stored. Original values are not.</p><p>Standard Bloom filters also move bits in only one direction. Insertions turn positions on, but nothing turns those positions back off. That detail is part of why the structure stays small. It also explains why deletion needs a different variant, such as a counting Bloom filter, rather than the standard form.</p><h4>The Hash Checks</h4><p>Hashing turns a value into positions inside the bit array. Instead of relying on a single result, a Bloom filter uses <code>k</code> hash functions, so one inserted value maps to <code>k</code> different positions. Insert time sets all of those positions to <code>1</code>. Lookup time repeats the same calculations and checks them again.</p><p>Take an id like <code>user-9183</code>. First hash result may point to position <code>14</code>, a second to <code>67</code>, and a third to <code>122</code>. Inserting that id means flipping all three positions on. Checking it later means reading those same spots again. If position <code>67</code> is still <code>0</code>, the answer is definite no. No extra interpretation is needed because that value could not have been inserted earlier.</p><p>That definite no is what gives the structure its value. Exact membership structures keep more information and cost more memory. Bloom filters trade away exact yes answers in return for a much smaller footprint. No remains exact. Yes becomes probably.</p><p>This code ties hashing and bit checks into the same class:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c1544ae0-3dee-4519-bdcc-b20e94972040&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.nio.charset.StandardCharsets;
import java.util.BitSet;
import java.util.zip.CRC32;

public final class BloomMembership {
    private final BitSet bits;
    private final int size;
    private final int hashCount;

    public BloomMembership(int size, int hashCount) {
        this.size = size;
        this.hashCount = hashCount;
        this.bits = new BitSet(size);
    }

    public void add(String value) {
        for (int index : indexesFor(value)) {
            bits.set(index);
        }
    }

    public boolean probablyContains(String value) {
        for (int index : indexesFor(value)) {
            if (!bits.get(index)) {
                return false;
            }
        }
        return true;
    }

    private int[] indexesFor(String value) {
        int[] indexes = new int[hashCount];
        long hash1 = crc32(value);
        long hash2 = crc32(value + "#salt");

        for (int i = 0; i &lt; hashCount; i++) {
            long combined = hash1 + (long) i * hash2;
            indexes[i] = (int) Math.floorMod(combined, size);
        }

        return indexes;
    }

    private long crc32(String value) {
        CRC32 crc32 = new CRC32();
        crc32.update(value.getBytes(StandardCharsets.UTF_8));
        return crc32.getValue();
    }
}</code></pre></div><p>This version builds several positions from two base hash values. That keeps the example short while still matching the rule that each value touches several positions.</p><p>False positives come from overlap. <code>user-9183</code> may set bits <code>14</code>, <code>67</code>, and <code>122</code>. Later, <code>user-4410</code> may set bits <code>67</code>, <code>90</code>, and <code>122</code>. After enough insertions, the array contains marks left behind by different values that share some of the same positions. The filter never asks which value turned on a bit. It checks only that every needed position is already set.</p><p>Now the source of a false positive comes into play. Values that were never inserted can still land on positions that earlier values already turned on. If all of those positions are <code>1</code>, the filter answers probably present. Nothing is broken when that happens. That is the trade Bloom filters make to stay small in memory. Inserted items do not produce false negatives, but absent items can still return a positive result.</p><p>Hash quality still matters because poor distribution fills some parts of the array faster than others. Better distribution spreads writes across the bit array more evenly, which helps keep the false positive rate closer to what was planned.</p><p>As a whole, the hash step turns a plain bit array into a membership filter. Without hash calculations, the array is just a block of bits. With repeatable position selection for each value, those bits become a compressed record of prior inserts.</p><h3>How It Blocks Repeated Misses</h3><p>Read traffic for absent ids can drain cache and storage in a very wasteful loop. Redis returns nothing, the database returns nothing, and the caller still gets only a not-found response. Then that same lookup can come back a second later and burn through that full chain again. Putting a Bloom filter at the front changes that loop. Definite negatives stop before Redis and before the database, while probable positives continue through the usual lookup flow.</p><h4>The Absent Row Problem</h4><p>Repeated lookups for data that does not exist are expensive because every request pays for a miss all the way down. Deleted rows, stale links, typoed ids, client bugs, and bot traffic can all create that kind of pressure. Without a front gate, storage keeps getting asked the same unanswerable question. Bloom filters fit this case well because their negative answer is definitive for membership. If the filter says an id is not present, the service can stop right there instead of burning another trip through cache and storage.</p><p>Hot-value expiry is a different failure mode. That case is about concurrent requests piling onto the backend after cached content is missing or expired. Bloom filters help earlier in the flow. Their job is to cut off lookups for absent ids before those requests ever reach Redis or the database.</p><h4>The Request Path</h4><p>Most services place the filter before both Redis and the database. Existing ids are loaded into the filter during startup, backfill, or a sync process, and new writes should add their ids too so the filter stays close to current data. After that, each read starts with a membership check. <code>false</code> returns not found immediately. <code>true</code> means probably present, so the request still moves into the normal cache lookup and then to the database if Redis misses.</p><p>The read flow looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c3a17cf6-8d01-44f2-b32f-3c7d3541bc68&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public final class ProfileLookupService {
    private final ProfileIdGate profileIdGate;
    private final RedisClient redisClient;
    private final ProfileRepository profileRepository;

    public ProfileLookupService(
            ProfileIdGate profileIdGate,
            RedisClient redisClient,
            ProfileRepository profileRepository) {
        this.profileIdGate = profileIdGate;
        this.redisClient = redisClient;
        this.profileRepository = profileRepository;
    }

    public ProfileResult readProfile(long profileId) {
        String key = "profile:" + profileId;

        if (!profileIdGate.probablyHas(key)) {
            return ProfileResult.notFound();
        }

        String cachedJson = redisClient.get(key);
        if (cachedJson != null) {
            return ProfileResult.found(cachedJson);
        }

        ProfileRow row = profileRepository.findById(profileId);
        if (row == null) {
            return ProfileResult.notFound();
        }

        String json = ProfileJson.write(row);
        redisClient.set(key, json, 300);
        return ProfileResult.found(json);
    }
}</code></pre></div><p>Bloom checking is not the source of truth in that flow. Positive means only that the lookup should continue. Redis or the database still decides the final result. Negative is the special case that saves time, because Bloom filters do not produce false negatives for items that were added.</p><p>Current Redis Bloom commands works nicely with that model:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;cf34d4fa-35e1-4843-8499-b5037a6fbb10&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">BF.RESERVE profiles 0.001 1000000
BF.MADD profiles profile:101 profile:205 profile:390
BF.EXISTS profiles profile:101
BF.EXISTS profiles profile:999999</code></pre></div><p>Pre-sizing helps because the filter starts with the capacity and error rate you intended, rather than falling back to defaults and growing later in a less predictable way. That becomes more important as the dataset grows and the filter carries a larger share of read traffic.</p><h4>The Memory Math</h4><p>Sizing is where Bloom filters move from theory into planning. Redis documents the bits-per-item rule as <code>-ln(error_rate) / ln(2)^2</code> and the optimal hash-count rule as <code>ceil(-ln(error_rate) / ln(2))</code>. Those formulas turn quickly into numbers you can budget. <code>1%</code> as a target needs <code>7</code> hash functions and <code>9.585</code> bits per item. <code>0.1%</code> needs <code>10</code> hash functions and <code>14.378</code> bits per item.</p><p>For <code>10,000,000</code> ids at <code>1%</code>, that comes out to about <code>95,850,000</code> bits, which is roughly <code>12 MB</code>. For <code>50,000,000</code> ids at the same rate, the filter lands near <code>60 MB</code>. <code>1,000,000,000</code> ids at <code>1%</code> comes out around <code>1.2 GB</code>. That is still a very small memory bill compared with holding full values or a heavier lookup structure just to reject absent ids.</p><p>Capacity planning has a direct effect on runtime cost. Redis supports scaling Bloom filters that can expand by adding subfilters, but those extra layers raise both storage and CPU cost compared with reserving the filter at the right size from the start. That does not mean scaling is wrong. It means a well-sized filter is cheaper to query and cheaper to store.</p><h4>False Positives in Production</h4><p>Positive answers from a Bloom filter are not proof that data exists. They mean only that the lookup should continue. If the filter says yes, Redis misses, and the database misses too, that request was a false positive. The response is still right. The caller gets not found, just as expected. False positives can happen, but false negatives do not occur for items that were added.</p><p>Tracking that number helps because it tells you how much absent traffic is still slipping past the front gate. Counters can do that job:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;daf871c6-fd23-4337-bfa0-71cd01f60b7a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.concurrent.atomic.AtomicLong;

public final class BloomFilterMetrics {
    private final AtomicLong bloomPasses = new AtomicLong();
    private final AtomicLong bloomFalsePositives = new AtomicLong();

    public void recordBloomPass() {
        bloomPasses.incrementAndGet();
    }

    public void recordFalsePositive() {
        bloomFalsePositives.incrementAndGet();
    }

    public double falsePositiveRate() {
        long passes = bloomPasses.get();
        if (passes == 0) {
            return 0.0;
        }
        return (double) bloomFalsePositives.get() / passes;
    }
}</code></pre></div><p>Rising false-positive rates usually point to a filter that is too full, a capacity estimate that was too low, or an error-rate target that no longer fits the dataset. Common fixes are a sizing change, a rebuild, or a lower target error rate with more bits per item.</p><p>Bloom filters also do not replace the protections used for expired hot entries. Resource locking and request coalescing still belong to the cache-stampede problem, where concurrent requests try to refill the same missing content at the same time. Bloom filters solve a narrower issue. They cut down the number of absent ids that ever get that far.</p><h3>Conclusion</h3><p>Bloom filters change the cost of a miss by turning it into a fast membership check before Redis or the database is touched. Bit positions record prior inserts, hash checks test those positions again during reads, and that small gate lets definite negatives stop early while probable positives continue through cache and storage. Memory stays low because the filter keeps bits instead of full values, and the trade is a measured false-positive rate in exchange for far fewer repeated misses against absent data.</p><ol><li><p><em><a href="https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/">Redis Bloom Filter Commands</a></em></p></li><li><p><em><a href="https://redis.io/docs/latest/commands/bf.reserve/">Redis </a></em><code>BF.RESERVE</code><em><a href="https://redis.io/docs/latest/commands/bf.reserve/"> Command</a></em></p></li><li><p><em><a href="https://redis.io/docs/latest/commands/bf.add/">Redis </a></em><code>BF.ADD</code><em><a href="https://redis.io/docs/latest/commands/bf.add/"> Command</a></em></p></li><li><p><em><a href="https://redis.io/docs/latest/commands/bf.exists/">Redis </a></em><code>BF.EXISTS</code><em><a href="https://redis.io/docs/latest/commands/bf.exists/"> Command</a></em></p></li><li><p><em><a href="https://redis.io/docs/latest/commands/bf.exists/">Java </a></em><code>BitSet</code><em><a href="https://redis.io/docs/latest/commands/bf.exists/"> Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/zip/CRC32.html">Java </a></em><code>CRC32</code><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/zip/CRC32.html"> Documentation</a></em></p></li><li><p><em><a href="https://guava.dev/releases/33.2.1-jre/api/docs/com/google/common/hash/BloomFilter.html">Guava </a></em><code>BloomFilter</code><em><a href="https://guava.dev/releases/33.2.1-jre/api/docs/com/google/common/hash/BloomFilter.html"> Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!d3DR!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!d3DR!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!d3DR!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!d3DR!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!d3DR!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!d3DR!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!d3DR!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!d3DR!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!d3DR!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!d3DR!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F498310e3-0e78-43a2-8c12-9094113b22f0_276x276.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://icons8.com/icon/90519/spring-boot">Spring Boot</a> icon by <a href="https://icons8.com/">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Reservoir Sampling in Java]]></title><description><![CDATA[Sampling from a stream gets tricky when items arrive one at a time, the total length is unknown, and storing every item in memory is not practical.]]></description><link>https://alexanderobregon.substack.com/p/reservoir-sampling-in-java</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/reservoir-sampling-in-java</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Fri, 10 Apr 2026 17:02:38 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!eVQ2!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!eVQ2!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!eVQ2!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!eVQ2!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!eVQ2!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!eVQ2!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!eVQ2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!eVQ2!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!eVQ2!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!eVQ2!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!eVQ2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8371cb8f-2062-4dd2-9120-34af6d53442b_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Sampling from a stream gets tricky when items arrive one at a time, the total length is unknown, and storing every item in memory is not practical. You run into that with file lines, database cursors, log events, iterators, and network feeds. Reservoir sampling handles it by keeping only a fixed number of items while still giving every item seen so far the same chance of ending up in the final sample. That balance is what makes it useful for large input or data sources that do not have a known end.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>Why Reservoir Sampling Exists</h3><p>Streaming input creates a hard sampling problem. Items arrive one at a time, the total count may stay unknown, and storing every value can be too expensive. Reservoir sampling was created for exactly that setting. It keeps a small sample in memory while input keeps moving forward, and it does so without giving early or late arrivals an unfair edge. Low memory use and fair selection are the whole reason the method exists.</p><h4>A Fixed Memory Sample From a Growing Input</h4><p>Memory pressure is the first issue reservoir sampling handles. Think about a source that keeps handing you values, such as file lines, rows from a database cursor, log entries, or data from a network feed. You can read each item as it arrives, but keeping all of them may not be realistic. Some inputs are huge. Some keep flowing for so long that reading everything first defeats the point of sampling. In that setting, a random sample still has value, while a full copy of the input does not.</p><p>Reservoir sampling handles that by picking a sample size up front. Call it <code>k</code>. The method keeps a container with exactly <code>k</code> slots after the first fill phase. The first <code>k</code> items go into those slots directly. After that, every new item faces a decision. It does not automatically enter the sample, and it is not automatically rejected. Instead, it gets a chance to replace one of the stored items.</p><p>That replacement rule is what keeps memory fixed. No matter how long the stream gets, the reservoir never grows past <code>k</code>. If <code>k</code> is <code>100</code>, then the sample storage stays at <code>100</code> items after the initial fill. The stream length could be <code>1,000</code>, <code>1,000,000</code>, or still rising, and the storage tied to the sample stays the same.</p><p>Take a short trace to make that flow easier to follow:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;9066196e-2e01-4886-b77a-ebad94d459c9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">int k = 3;
int[] reservoir = {12, 19, 25};

// item 4 arrives with value 31
// pretend the random draw is 1 from the range 0..3
reservoir[1] = 31;   // reservoir becomes [12, 31, 25]

// item 5 arrives with value 44
// pretend the random draw is 4 from the range 0..4
// no replacement happens, reservoir stays [12, 31, 25]

// item 6 arrives with value 58
// pretend the random draw is 0 from the range 0..5
reservoir[0] = 58;   // reservoir becomes [58, 31, 25]</code></pre></div><p>That trace captures the flow of the method. The sample does not grow as input grows. It stays fixed, and incoming items compete for a slot. Early values are not locked in place. Later values still get a chance to enter. That balance is why the method stays useful on long input instead of turning into a biased record of only what arrived first.</p><p>Storage is only part of the story, though. A method that keeps memory low would not help much if it quietly favored one part of the stream over another. Reservoir sampling was built so the sample stays fair while the stream moves forward. Fairness comes from the replacement probability, not from storing everything first and picking later.</p><p>There is also a count-only view that helps make the memory side feel more tangible:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;0b11501c-07ca-4f56-833f-899cd49c1827&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">int processed = 0;
int k = 5;

// after reading 5 items
processed = 5;   // reservoir size is 5

// after reading 500 items
processed = 500; // reservoir size is still 5

// after reading 50_000 items
processed = 50_000; // reservoir size is still 5</code></pre></div><p>The value in that count view is not the arithmetic itself. What stands out is the fact that processed input keeps rising while reservoir size stays flat. Reservoir sampling does not try to keep a large share of the stream. It tries to keep a fair sample of a chosen size without letting memory grow with the stream length.</p><h4>Why Every Item Gets the Same Chance</h4><p>Fairness is the second issue reservoir sampling handles, and this is where the logic gets more interesting. The method is built so every item seen so far has the same final chance to still be in the reservoir. That statement sounds compact, but walking through it slowly helps because the replacement rule can feel unusual at first.</p><p>Start with the smallest case, where the reservoir size is <code>1</code>. The first item becomes the current sample. The second item replaces it with probability <code>1 / 2</code>. The third item replaces the current sample with probability <code>1 / 3</code>. The fourth item gets probability <code>1 / 4</code>, and that same rule keeps going as the stream grows.</p><p>That can feel uneven at first because later items get smaller entry chances. The balancing force comes from survival. Early items get into the sample sooner, but they also face more replacement rounds. Later items get fewer entry chances, but they also face fewer chances of being removed after entry. Those two effects balance out.</p><p>Take the second item in a stream of five items. It gets selected when it arrives with probability <code>1 / 2</code>. After that, it has to survive item three, item four, and item five. Its survival chances across those steps are <code>2 / 3</code>, <code>3 / 4</code>, and <code>4 / 5</code>. Multiply those terms and the result is <code>1 / 5</code>.</p><p>Now look at the fifth item. It enters with probability <code>1 / 5</code>, and there are no later items left to remove it. Its final probability is also <code>1 / 5</code>. The first, second, third, fourth, and fifth items all end with the same final chance.</p><p>Quick numeric checks put numbers on that idea:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;16b94901-b9a8-4c61-9123-2494aae48b5f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">double secondItemFinalChance =
        (1.0 / 2.0) *
        (2.0 / 3.0) *
        (3.0 / 4.0) *
        (4.0 / 5.0);

double fifthItemFinalChance = 1.0 / 5.0;

System.out.println(secondItemFinalChance); // 0.2
System.out.println(fifthItemFinalChance);  // 0.2</code></pre></div><p>Both printed values are <code>0.2</code>, which is the same as <code>1 / 5</code>. That is the fairness rule in action for the size <code>1</code> case.</p><p>Take a reservoir with size <code>k</code> next. For an item arriving at position <code>i</code>, there are <code>i</code> possible random outcomes at that moment, and only <code>k</code> of them lead to entry into the reservoir. So the chance to enter is <code>k / i</code>.</p><p>After that item enters, later arrivals can remove it, but not all later arrivals will do that. At position <code>i + 1</code>, a replacement happens with chance <code>k / (i + 1)</code>, and only one reservoir slot gets replaced. That means a stored item survives that step with probability <code>i / (i + 1)</code>. That same survival factor keeps repeating as more items arrive.</p><p>By the time the stream reaches position <code>n</code>, the full probability for an item that arrived at position <code>i</code> becomes</p><p><code>(k / i) &#215; (i / (i + 1)) &#215; ((i + 1) / (i + 2)) &#215; ... &#215; ((n - 1) / n)</code></p><p>Most of the middle terms cancel, leaving <code>k / n</code>.</p><p>That cancellation is the heart of the method. A later item gets a smaller entry chance, but it also faces fewer future chances to be removed. An earlier item gets into the reservoir sooner, but it faces more future chances to be replaced. The arithmetic balances those effects so the final probability stays the same.</p><p>The first <code>k</code> items follow that same logic. They enter immediately because the reservoir still has open space. After the reservoir fills, they begin facing replacement risk from later arrivals. By the time the stream ends at position <code>n</code>, each of those first <code>k</code> items also ends with final probability <code>k / n</code>. So the early part of the stream and the late part of the stream finish on equal footing.</p><p>That is why random replacement is not a rough shortcut. It is the reason the sample stays fair. Fixed-size storage by itself would not be enough. Fair storage comes from letting new arrivals challenge older entries with exactly the right probability at each step.</p><h3>Reservoir Sampling in Modern Java</h3><p>Java fits reservoir sampling well because the language already gives you the parts this algorithm needs. You need sequential access to incoming values, a bounded random draw after the reservoir fills, and a place to hold only the chosen sample. Current Java covers that through <code>Iterator</code>, generic collections, and the <code>RandomGenerator</code> family. An implementation can stay fairly small, but the details still deserve careful attention, particularly around counting, bounded random selection, and the generator passed into the method.</p><h4>Sample of One Item</h4><p>Start with the smallest form first, reservoir sampling with size <code>1</code> keeps only one current choice while the source moves forward. The first item becomes the current value right away. After that, each new item gets a replacement chance of <code>1 / n</code>, where <code>n</code> is the number of items seen so far. In Java, that maps nicely to a bounded random draw. If the random value comes back as zero, the new item replaces the stored item. If not, the stored item stays in place. <code>RandomGenerator</code> fits well here because it gives bounded methods such as <code>nextLong(long bound)</code>, and that method returns a value from zero inclusive up to the bound exclusive.</p><p>This compact implementation keeps the full rule in one place:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c11ae2d8-09b8-46da-bb3d-140e1e699605&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.Iterator;
import java.util.Optional;
import java.util.random.RandomGenerator;

public final class SingleItemSampler {
    private SingleItemSampler() {
    }

    public static &lt;T&gt; Optional&lt;T&gt; sampleOne(Iterator&lt;T&gt; source, RandomGenerator random) {
        T chosen = null;
        long seen = 0;

        while (source.hasNext()) {
            T item = source.next();
            seen++;

            if (random.nextLong(seen) == 0) {
                chosen = item;
            }
        }

        return seen == 0 ? Optional.empty() : Optional.of(chosen);
    }
}</code></pre></div><p>The <code>seen</code> counter is doing more than keeping a tally. It is part of the probability rule itself. On item <code>1</code>, <code>nextLong(1)</code> can only return <code>0</code>, so the first item is stored. On item <code>2</code>, the method draws from <code>0</code> through <code>1</code>, so replacement happens half the time. On item <code>3</code>, replacement happens a third of the time. That same flow continues through the entire input. Returning <code>Optional.empty()</code> for an empty source also keeps the result honest instead of inventing a placeholder value that never appeared.</p><p>Take a short call site to make that flow easier to read:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;b2e2b7b2-4769-45a3-8a57-e5b802180505&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.List;
import java.util.SplittableRandom;
import java.util.random.RandomGenerator;

List&lt;String&gt; events = List.of("login", "search", "checkout", "logout");
RandomGenerator random = new SplittableRandom(2026L);

var chosen = SingleItemSampler.sampleOne(events.iterator(), random);
System.out.println(chosen.orElse("no event"));</code></pre></div><p>Passing the generator in from the outside keeps the sampling method focused on the replacement rule rather than generator creation. That also gives you repeatable runs during tests when you use a fixed seed, or thread-local generation when sampling happens inside concurrent code. <code>SplittableRandom(long seed)</code> is handy for repeatable output, while <code>RandomGenerator</code> keeps the method open to several generator types without changing the algorithm.</p><h4>Sample of <code>k</code> Items</h4><p>Move from size <code>1</code> to size <code>k</code>, and the structure gets wider without changing the probability rule. The first <code>k</code> items fill the reservoir directly. After the reservoir is full, each new item gets a random slot from the range of items seen so far. If the chosen number falls inside the reservoir range, replacement happens at that slot. If the number falls outside that range, the new item is skipped. Java collections fit this very naturally, and <code>ArrayList</code> is a strong choice here because indexed replacement through <code>set</code> is direct.</p><p>This generic method covers that version:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;761af1a3-14e3-435b-8f14-bf1a680b10d2&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.random.RandomGenerator;

public final class ReservoirSampler {
    private ReservoirSampler() {
    }

    public static &lt;T&gt; List&lt;T&gt; sample(Iterator&lt;T&gt; source, int k, RandomGenerator random) {
        if (k &lt; 0) {
            throw new IllegalArgumentException("k must be at least 0");
        }

        List&lt;T&gt; reservoir = new ArrayList&lt;&gt;(k);
        long seen = 0;

        while (source.hasNext() &amp;&amp; reservoir.size() &lt; k) {
            reservoir.add(source.next());
            seen++;
        }

        while (source.hasNext()) {
            T item = source.next();
            seen++;

            long slot = random.nextLong(seen);
            if (slot &lt; k) {
                reservoir.set((int) slot, item);
            }
        }

        return reservoir;
    }
}</code></pre></div><p>Two counters are active in that method. <code>reservoir.size()</code> tells you how full storage is during the first fill phase. <code>seen</code> tracks how several items have passed through the sampler in total. That second value is the part tied to probability. If <code>seen</code> is <code>10</code> and <code>k</code> is <code>3</code>, then only three random outcomes out of the ten possible values from <code>nextLong(10)</code> lead to entry into the reservoir. That gives the current item its correct <code>3 / 10</code> entry chance.</p><p>Choosing <code>long</code> for <code>seen</code> is helpful for long-running input. The reservoir still relies on indexed collection access, so <code>k</code> remains an <code>int</code>, but the count of processed items does not have to stop at <code>Integer.MAX_VALUE</code>. The cast from <code>slot</code> to <code>int</code> is safe here because the cast only happens after the code checks <code>slot &lt; k</code>, and <code>k</code> already fits in <code>int</code>.</p><p>Edge behavior deserves a short note too. If the source ends before <code>k</code> items arrive, the method returns all available items. That matches the problem naturally because there is no way to return a sample of ten distinct items from an input that only had six.</p><p>Sampling from file lines gives this method a natural place to run:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e03e267e-b422-4e7f-b93f-dac6f049ff9d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.SplittableRandom;

Path logFile = Path.of("events.log");

try (var lines = Files.lines(logFile)) {
    List&lt;String&gt; picked = ReservoirSampler.sample(
            lines.iterator(),
            5,
            new SplittableRandom(9001L)
    );

    for (String line : picked) {
        System.out.println(line);
    }
}</code></pre></div><p>That example stays close to what reservoir sampling is built for. The file can be read as a stream of lines, the sampler stores only five of them, and the method does not need the full line count ahead of time. <code>Files.lines</code> also makes the sequential nature of the source very visible, which pairs nicely with a one-pass sampling method.</p><h4>Picking a Generator in Current Java</h4><p>Generator choice affects the call site more than the reservoir logic itself, but it still deserves attention because current Java gives you several strong options. <code>RandomGenerator</code> is the shared interface, so the sampling method can accept that type and stay open to several generator classes. That keeps the algorithm from being tied to any single generator implementation.</p><p><code>ThreadLocalRandom</code> is a strong fit when each thread is sampling independently. <code>ThreadLocalRandom.current()</code> returns the generator for the current thread, which avoids sharing a single mutable generator across threads. That is useful when sampling runs inside concurrent code and each thread has its own input flow. <code>SplittableRandom</code> is also a very good fit. It was built with independent random generation in mind, and it supports splitting into new generators that can continue separately. It also has a constructor that takes a seed, which is useful when you want repeatable output during tests or while checking example runs.</p><p>A short comparison at the call site makes those choices easier to see:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;0ebaca8a-783e-4da3-883b-5b2416371811&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.List;
import java.util.SplittableRandom;
import java.util.concurrent.ThreadLocalRandom;
import java.util.random.RandomGenerator;

List&lt;Integer&gt; values = List.of(3, 8, 13, 21, 34, 55, 89);

RandomGenerator seeded = new SplittableRandom(13579L);
List&lt;Integer&gt; seededSample = ReservoirSampler.sample(values.iterator(), 3, seeded);

RandomGenerator perThread = ThreadLocalRandom.current();
List&lt;Integer&gt; threadSample = ReservoirSampler.sample(values.iterator(), 3, perThread);</code></pre></div><p>The first call is repeatable for the same seed within the same generator class, which is useful in tests. The second call suits per-thread activity better. Neither choice changes the reservoir algorithm itself. The algorithm only needs bounded random selection through the <code>RandomGenerator</code> API.</p><p>Security-sensitive code can call for a different generator family. <code>SecureRandom</code> belongs in that category. Because <code>SecureRandom</code> extends <code>Random</code>, and <code>Random</code> implements <code>RandomGenerator</code>, it can also be passed to the same method signature when that kind of generator is needed.</p><h4>Cost on Large Input</h4><p>Performance is one reason reservoir sampling remains attractive in Java. The sampler reads each item exactly one time in the basic form, so time cost rises linearly with the number of processed items. Storage stays tied to the sample size <code>k</code>, not to the full input length. In big-O terms, that gives <code>O(n)</code> time for a pass over <code>n</code> items and <code>O(k)</code> space for the reservoir. If the sample size is <code>1</code>, storage drops to <code>O(1)</code>.</p><p>What counts in practice is that the method does not need random access into the original source and does not need to buffer the full stream before deciding. <code>Iterator&lt;T&gt;</code> is enough. That matches file lines, cursor-backed results, stream iterators, and any other source that is already moving forward item by item.</p><p>There is also a useful boundary to keep in mind. Reservoir sampling gives a uniform sample from the sequence that actually reaches the sampler. If earlier filtering has already changed that sequence, the sampler will faithfully sample the filtered sequence rather than the raw source. So the fairness guarantee belongs to the data that entered the algorithm, not to data that never reached it.</p><p>Very large sampling jobs have inspired skip-based variants that reduce per-item random decisions. Those versions belong to the broader family, but the plain reservoir replacement rule is still the best starting point for most Java articles and codebases because it is short, readable, and lines up well with the standard library tools already in place.</p><h3>Conclusion</h3><p>Reservoir sampling comes down to a small set of moving parts. You keep a fixed reservoir, fill it with the first <code>k</code> items, then let each later item compete for a slot based on how far into the stream it arrived. That replacement rule is what keeps memory flat while still giving every seen item the same final chance to remain in the sample. Java fits that flow well through <code>Iterator</code>, bounded random draws, and a small collection for storage, which is why the method holds up so well for long streams and unknown input sizes.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/random/RandomGenerator.html">RandomGenerator Interface</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/SplittableRandom.html">SplittableRandom Class</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ThreadLocalRandom.html">ThreadLocalRandom Class</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/security/SecureRandom.html">SecureRandom Class</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Iterator.html">Iterator Interface</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/file/Files.html">Files Class</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/file/Path.html">Path Interface</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ArrayList.html">ArrayList Class</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Hash Ring Based Client-Side Load Balancing Decisions in Spring Boot]]></title><description><![CDATA[Client-side ring selection lets a client choose a backend in a repeatable way without handing that choice to a central balancer.]]></description><link>https://alexanderobregon.substack.com/p/hash-ring-based-client-side-load</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/hash-ring-based-client-side-load</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Wed, 08 Apr 2026 16:22:47 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/dbc62f77-c68e-4f3d-b313-9a0ccf88b336_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!fEKA!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!fEKA!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!fEKA!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!fEKA!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!fEKA!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!fEKA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!fEKA!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!fEKA!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!fEKA!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!fEKA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F329a22d8-a699-4860-bde6-e51b7c974eea_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://spring.io/projects/spring-boot">Image Source</a></figcaption></figure></div><p>Client-side ring selection lets a client choose a backend in a repeatable way without handing that choice to a central balancer. The client hashes a stable request value, such as a tenant ID, session ID, account ID, or cache key, then maps it onto a ring where backends are placed through virtual points. The chosen target is the first backend clockwise from that hash position. If the backend set stays the same, the same request keeps going to the same backend. If one backend goes down, only that backend&#8217;s portion of the ring gets reassigned. That is why ring hashing is useful for sticky routing, caches, sharded reads, and similar traffic. Service discovery works nicely with that flow by giving the client an updated list of available instances so it can rebuild the ring from the active set.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>Picking a Target on the Ring</h3><p>Client-side ring selection starts with a stable request value and ends with one backend chosen from a shared ring. That sentence sounds compact, but a fair amount happens between those two points. A client needs a repeatable way to turn request data into a number, a repeatable way to place backend entries on the ring, and a repeatable way to walk that ring so the same request token keeps reaching the same backend while membership stays the same.</p><p>Most of the behavior people care about in a ring begins right here. Stable placement does not come from luck. It comes from feeding the same input into the same hash function, keeping the ring layout consistent across clients, and walking clockwise in the same way every time. Once those rules stay fixed, target selection becomes predictable enough to support sticky routing, cache locality, and shard-aware traffic without handing every request to a central chooser.</p><h4>Request Hash Meets Ring Points</h4><p>Everything starts with the request value you decide to hash. That value has to stay tied to the thing you want to keep on one backend. Session traffic usually hashes a session identifier. Tenant traffic usually hashes a tenant identifier. Cache traffic usually hashes the cache key itself. If the client hashes something that changes from one call to the next, placement changes with it, and the ring stops giving you repeatable target selection for that unit of traffic.</p><p>Each request hash is just a number until it is compared with the ring. Backends already have points placed around the circle, and the client looks for the first backend point at or after the request hash while moving clockwise. If the request hash lands near the end of the ring and no backend point appears after it, the client wraps back to the first point at the start of the ring. That wraparound step is part of the ring lookup, not a special second rule bolted on later.</p><p>Java&#8217;s <code>NavigableMap</code> is a handy fit for that lookup because it keeps points ordered. A request hash can be matched with <code>ceilingEntry</code>, and if that returns <code>null</code>, the client wraps to <code>firstEntry</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;10dbc244-9f39-401a-adc9-c19b2103f0d2&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">import java.util.NavigableMap;
import java.util.TreeMap;
import java.util.Map;

public final class RingLookup {
    private final NavigableMap&lt;Long, String&gt; ring = new TreeMap&lt;&gt;();

    public RingLookup() {
        ring.put(102L, "backend-east-1");
        ring.put(340L, "backend-west-2");
        ring.put(615L, "backend-east-2");
        ring.put(910L, "backend-central-1");
    }

    public String pick(long requestPoint) {
        Map.Entry&lt;Long, String&gt; entry = ring.ceilingEntry(requestPoint);
        if (entry != null) {
            return entry.getValue();
        }
        return ring.firstEntry().getValue();
    }
}</code></pre></div><p>The lookup rule stays the same no matter how the request point was produced. What changes is the request value chosen before hashing. That part deserves care because it decides what kind of stickiness the client gets. Hashing a tenant ID keeps one tenant near one backend. Hashing a shopping cart ID keeps one cart near one backend. Hashing a per-request timestamp would make the ring behave almost randomly for that traffic, which defeats the purpose of ring-based selection in the first place.</p><p>Picking the hash input also affects how evenly request ownership spreads across the ring. If almost every request shares the same small set of identifiers, the ring may still be behaving exactly as written while traffic piles onto a narrow slice of backends. That is not a ring failure. That is a property of the request data. Good placement starts with a request token that has enough variety for the traffic you are routing.</p><p>This gets easier to understand with a small helper method that picks the token based on the kind of placement you want to preserve:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;94a14633-07b7-4e25-b168-070bf6986387&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">public final class RequestTokenSelector {

    public String selectToken(ClientRequest request) {
        if (request.sessionId() != null &amp;&amp; !request.sessionId().isBlank()) {
            return request.sessionId();
        }
        if (request.tenantId() != null &amp;&amp; !request.tenantId().isBlank()) {
            return request.tenantId();
        }
        return request.accountId();
    }
}

record ClientRequest(String sessionId, String tenantId, String accountId) {}</code></pre></div><p>Notice what this code is really doing. It is deciding what should stay attached to one backend. That choice comes before any ring lookup happens, and it has a lasting effect on the traffic profile the client creates.</p><p>Placement on the ring itself also needs to be identical wherever the client logic runs. If one client builds backend points in a different order, hashes backend identifiers differently, or skips some points another client included, two clients can send the same request token to different backends. Ring hashing depends on shared rules, not on one lucky calculation. Same request token, same backend membership, same hashing steps, same ring walk. That is the chain that makes target selection repeatable.</p><h4>Stable Placement Through Virtual Points</h4><p>One ring point per backend sounds fine at first, but it usually produces rough ownership slices. Some backends end up with wide arcs, others with narrow ones, and traffic share drifts more than people expect. Virtual points fix that by placing each backend on the ring multiple times instead of just once. Each placement claims a small arc, and the combined arcs for that backend add up to its total share.</p><p>More points usually mean better balance across the circle, but that does not mean the ring becomes mathematically perfect. Hashing still spreads points based on the hash results, so some variation remains. What changes is the scale of that variation. With a thin ring, one unlucky placement can give a backend too much space. With a fuller ring, ownership is broken into smaller slices, which pulls the final share closer to what the client intended.</p><p>Weighted rings handle this by turning weight into repeated placements around the ring. If one backend is meant to carry about twice the share of another, it usually gets about twice as many virtual points. That means the client is not keeping a single backend entry and attaching extra traffic to it later. It is giving that backend more positions on the ring from the start, which gives it a larger share of ownership during lookup.</p><p>To see how that looks in code, a ring builder can place the same backend on the ring multiple times based on its weight:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;ee16e9c0-528a-422d-9b00-57d33b7ebd63&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;
import java.util.zip.CRC32;

public final class WeightedRingBuilder {
    private final NavigableMap&lt;Long, String&gt; ring = new TreeMap&lt;&gt;();

    public WeightedRingBuilder(Map&lt;String, Integer&gt; weights, int pointsPerWeightUnit) {
        for (Map.Entry&lt;String, Integer&gt; entry : weights.entrySet()) {
            String backendId = entry.getKey();
            int weight = entry.getValue();

            for (int i = 0; i &lt; weight * pointsPerWeightUnit; i++) {
                long point = hash32(backendId + "#" + i);
                ring.put(point, backendId);
            }
        }
    }

    public NavigableMap&lt;Long, String&gt; ring() {
        return ring;
    }

    private long hash32(String value) {
        CRC32 crc32 = new CRC32();
        crc32.update(value.getBytes(StandardCharsets.UTF_8));
        return crc32.getValue();
    }
}</code></pre></div><p>The <code>backendId + "#" + i</code> part is what creates a different hash input for the same backend. Without that suffix, repeated hashing of the same backend identifier would keep landing on the same point, which would defeat the whole point of virtual placement. Each numbered entry usually lands at a different location on the ring, though hash collisions can still map two entries to the same point.</p><p>Traffic ownership gets easier to follow when the ring is viewed as a long run of small ownership slices rather than a single slice for each backend. The request token hashes to one location, then the client moves clockwise until it reaches the first virtual point. That point belongs to a backend, so that backend gets the request. Distance to the backend&#8217;s next virtual point does not change that result. If the same request token comes back later, it hashes to the same location and reaches that same winner again.</p><p>To make that easier to see in code, take this inspection helper that can count how those virtual points are distributed across the ring:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;79b49fad-d170-461c-9243-56cbfe058c75&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">import java.util.Map;
import java.util.HashMap;
import java.util.NavigableMap;

public final class RingOwnershipCounter {

    public Map&lt;String, Integer&gt; countPoints(NavigableMap&lt;Long, String&gt; ring) {
        Map&lt;String, Integer&gt; totals = new HashMap&lt;&gt;();

        for (String backendId : ring.values()) {
            totals.merge(backendId, 1, Integer::sum);
        }

        return totals;
    }
}</code></pre></div><p>Counting ring entries does not tell you the exact request share by itself, but it does give a quick read on how virtual placement was distributed. If a backend was meant to carry twice the traffic of another backend, its point count should usually be about twice as large as well. From there, the actual arc lengths still depend on where those points landed after hashing.</p><p>Ring size affects how placement behaves in practice too. Sparse rings with very few virtual points can create large arcs, so small changes in point placement carry more weight. Fuller rings break ownership into smaller arcs, which keeps distribution closer to the intended shares. That is why virtual points are not just an extra tuning choice. They are part of what makes ring-based target selection usable at scale.</p><p>Stable placement through virtual points also depends on backend identity staying consistent. If one refresh names a backend <code>orders-7</code> and the next refresh names that same backend <code>10.1.4.23</code>, the client hashes two different identifiers and places them at different positions. Membership may not have changed in any meaningful way, yet the ring would still be rearranged. Consistent backend identity is directly tied to consistent point placement.</p><p>Also, virtual points do not change the basic lookup rule. The request still hashes to one location, and the client still walks clockwise. What changes is the texture of the ring. Instead of a small number of wide ownership regions, the ring becomes a larger field of smaller regions that map back to backend identities in a more balanced way. That is what gives ring hashing its stable feel while keeping target selection repeatable enough for sticky traffic.</p><h3>Failure Handling With Discovery</h3><p>Routing on a ring gets more interesting the moment backend health starts changing. The request token still hashes to the same location, yet the client can no longer treat every backend on the ring as eligible. Discovery data and local health feedback are the two inputs that guide that decision. Discovery supplies a shared view of active membership, while local feedback lets the client react right after a connection or request fails. That split keeps the flow readable. Ring lookup still decides who owns traffic for a given token. Discovery and health status decide who is allowed to participate in that lookup at the current moment. From there, failover behavior, remap size, and refresh timing all follow from that same idea.</p><h4>Failover After a Dead Node</h4><p>Failover on a hash ring behaves very differently from modulo-based routing. With modulo hashing, a membership change can reshuffle a large share of placements because the divisor changes. On a ring, the token still hashes to the same location, and the client still moves clockwise from that point. What changed is the set of live backends. If a backend is gone, the client keeps moving until it reaches the next backend that is still eligible to receive traffic.</p><p>That part is what makes ring failover fairly intuitive once the lookup rule is already familiar. The token does not need to be reinterpreted during failure. No replacement hash is computed. No separate failover map is required. Traffic that used to stop at the dead backend now continues forward to the next live backend, while traffic that belonged elsewhere keeps the same destination.</p><p>Client implementations usually handle that in two broad ways. Some clients rebuild the active ring after discovery marks a backend unhealthy, then run lookup against that smaller ring. Others keep the full ring in memory for a short period and mark a failing backend as temporarily ineligible right after local errors such as connection refusal, TLS handshake failure, or repeated short timeouts. In both cases, the client still follows the same clockwise walk. The only difference is which source marked the backend unavailable.</p><p>This selector keeps that behavior visible in code:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;ee43e776-0e0a-40c5-ba88-467faf48f8fc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">import java.time.Instant;
import java.util.Map;
import java.util.NavigableMap;

public final class FailoverSelector {
    private final NavigableMap&lt;Long, String&gt; ring;
    private final Map&lt;String, Instant&gt; locallyBlockedUntil;

    public FailoverSelector(NavigableMap&lt;Long, String&gt; ring, Map&lt;String, Instant&gt; locallyBlockedUntil) {
        this.ring = ring;
        this.locallyBlockedUntil = locallyBlockedUntil;
    }

    public String pick(long requestPoint) {
        for (String backend : ring.tailMap(requestPoint, true).values()) {
            if (isUsable(backend)) {
                return backend;
            }
        }

        for (String backend : ring.headMap(requestPoint, false).values()) {
            if (isUsable(backend)) {
                return backend;
            }
        }

        throw new IllegalStateException("No live backend available");
    }

    private boolean isUsable(String backend) {
        Instant blockedUntil = locallyBlockedUntil.get(backend);
        return blockedUntil == null || Instant.now().isAfter(blockedUntil);
    }
}</code></pre></div><p>Nothing about the ring lookup changed in that class. The client still starts from the token&#8217;s ring position and still scans clockwise with wraparound. Local blocking only changes which backend can stop that scan.</p><p>Timing is the part that makes this worth calling out. Discovery data is never perfectly instantaneous. A backend can fail after the last refresh and still remain present in the client&#8217;s current membership view for a short period. During that gap, local feedback lets the client stop routing traffic to a backend that has already started failing. Later, discovery catches up and removes that backend from the shared active set.</p><p>That local suppression window belongs to client policy rather than to the ring itself. Short windows let a backend return to traffic sooner after a brief network issue. Longer windows reduce repeated failures to the same destination, though they also keep a recovered backend out of local rotation for more time. The ring defines the walk and health policy defines which stops on that walk are currently acceptable.</p><h4>How Much Traffic Moves</h4><p>Consistent hashing reduces remapping, but it does not remove it. If one backend disappears, the tokens that used to belong to that backend move forward to the next live backend on the ring. Tokens that mapped to other backends stay where they were. That is the practical meaning of minimal remapping in ring-based routing.</p><p>For equal-share backends on a ring with decent partitioning, removing one backend from a set of <code>N</code> usually moves about <code>1/N</code> of the tokens. Ten equal backends means roughly one tenth of the traffic moves after one loss. With a hundred equal backends, roughly one hundredth of the traffic moves. That estimate depends on the ring being partitioned well enough that backend ownership is close to the intended shares.</p><p>Smaller rings can drift from that target. If virtual points are sparse or bunched unevenly, one backend can own more arc length than intended, which means its failure moves more traffic than the equal-share estimate would suggest. That is why virtual point count and ring size have such a strong effect on observed remap size. They do not alter the clockwise lookup rule, but they do affect how evenly ownership is spread before anything fails.</p><p>Weighted backends follow the same logic. If a backend owns a larger share of the ring, losing that backend moves a larger share of traffic. No special outage formula is needed for that case. The amount of traffic that moves follows directly from how much ring ownership that backend had before it dropped out.</p><p>The same idea appears during scale-out. When a new backend joins, it takes over part of the ring from the current backends rather than forcing a full reshuffle. That is why ring-based routing is so useful for sticky traffic. Session tokens, tenant tokens, and shard tokens keep their placement unless the slice of the ring tied to that token changes ownership.</p><p>Remapping is easiest to follow when viewed as an ownership transfer around the ring. One backend loses its region, and neighboring live regions absorb it clockwise. The client is not rebalancing every token across the full backend set after a failure. It is handing off the failed backend&#8217;s portion to the next reachable owners on the circle.</p><h4>Discovery Feeds the Active Set</h4><p>Discovery answers a different question from the ring. The ring decides where traffic should go among the current candidates. Discovery decides which candidates belong in that active set at all. Keeping those two jobs separate makes the full flow much easier to reason about.</p><p>Kubernetes is a good example of that split. Backend membership for a <code>Service</code> is tracked through <code>EndpointSlice</code> objects. Clients that read those active endpoints can rebuild their ring from the current healthy membership instead of relying on a stale address list. The ring lookup itself does not change. What changes is who gets placed on the ring before lookup starts.</p><p>Consul follows the same overall model through a different API surface. Services register there, health checks update their status, and clients can read the healthy service view to populate the active backend set. The ring does not perform those health checks by itself. It consumes the active membership that discovery and health evaluation already produced.</p><p>This adapter keeps that handoff visible:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;f2f136ec-43c5-406e-bd1b-c0ca53bbd713&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public final class ActiveRingFactory {

    public WeightedRingBuilder fromDiscovery(List&lt;ServiceInstance&gt; instances, int pointsPerWeightUnit) {
        Map&lt;String, Integer&gt; weights = new LinkedHashMap&lt;&gt;();

        for (ServiceInstance instance : instances) {
            if (instance.healthy()) {
                weights.put(instance.instanceId(), instance.weight());
            }
        }

        return new WeightedRingBuilder(weights, pointsPerWeightUnit);
    }
}

record ServiceInstance(String instanceId, boolean healthy, int weight) {}</code></pre></div><p>That class does not perform discovery on its own. Its job is narrower. It takes membership data that discovery already returned, keeps only the healthy entries, and turns that active set into ring membership.</p><p>Clients that skip discovery can still build a ring, but membership then becomes fixed or manually maintained. That gets fragile in environments where instances appear and disappear, addresses change, or health status changes faster than configuration files do. Discovery without ring-based placement has the opposite weakness. The client knows who is active, but it still needs a repeatable rule for choosing one destination for a given token. Put those two pieces side by side, and the client gets live membership with stable placement.</p><p>Backend identity also needs to stay consistent across refreshes. If discovery reports the same backend under different identifiers from one update to the next, the client hashes different backend names and places them at different ring positions. Membership may be effectively the same, yet placement will still move because the ring sees those identifiers as different entries.</p><h4>Refresh Windows Plus Local Health</h4><p>Refresh timing decides how quickly discovery changes alter the ring, while local health decides what the client does between those refreshes. Those inputs are related, but they are not the same thing.</p><p>Shared discovery data gives the client a broader membership view. Local health gives the client the earliest sign that something just failed in front of it. Healthy routing flow uses both. Discovery is useful for giving the fleet a common view of membership. Local failure feedback is useful right after an outbound call starts failing.</p><p>Let&#8217;s see how that looks with a small tracker that keeps a short local quarantine window for failing backends:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;cf9e23e2-59b8-4b60-baed-e2578177929f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public final class LocalHealthTracker {
    private final ConcurrentMap&lt;String, Instant&gt; blockedUntil = new ConcurrentHashMap&lt;&gt;();
    private final Duration blockTime = Duration.ofSeconds(20);

    public void markFailure(String backendId) {
        blockedUntil.put(backendId, Instant.now().plus(blockTime));
    }

    public void markSuccess(String backendId) {
        blockedUntil.remove(backendId);
    }

    public boolean isBlocked(String backendId) {
        Instant until = blockedUntil.get(backendId);
        return until != null &amp;&amp; Instant.now().isBefore(until);
    }
}</code></pre></div><p>Local quarantine does not replace discovery membership. It covers the gap between refreshes. If a backend starts failing right now, the client does not have to wait for the next registry update before reacting.</p><p>Refresh rate also affects stickiness and traffic churn. Long refresh windows can leave dead nodes on the ring longer than they should stay there, which leads to repeated local failovers before discovery catches up. Extremely short refresh windows can rebuild the ring too aggressively and move traffic more frequently than needed. The best interval depends on how quickly backend state changes, how fast the discovery source updates health, and how expensive extra remaps are for the traffic being routed.</p><p>Not every discovery update needs a rebuild either. If fresh discovery data arrives without any change to active membership, rebuilding the ring serves little purpose and still adds churn inside the client.</p><p>This small snapshot check helps keep rebuilds tied to actual membership changes:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;da815b98-4856-4a69-9716-91599f5c9996&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">import java.util.List;
import java.util.Objects;

public final class MembershipSnapshot {
    private final List&lt;String&gt; activeBackendIds;

    public MembershipSnapshot(List&lt;String&gt; activeBackendIds) {
        this.activeBackendIds = activeBackendIds.stream()
                .sorted()
                .toList();
    }

    public boolean sameMembership(MembershipSnapshot other) {
        return Objects.equals(this.activeBackendIds, other.activeBackendIds);
    }

    public List&lt;String&gt; activeBackendIds() {
        return activeBackendIds;
    }
}</code></pre></div><p>Two health views are active through this whole flow. One comes from discovery and reflects the broader shared membership picture. The other comes from the client&#8217;s own request outcomes. Discovery can still report a backend as present while the client has already seen repeated failures to that same backend. Local suppression handles that near-term gap. After discovery removes the backend from the active set, the rebuilt ring no longer includes it as a candidate. That sequence fits naturally with ring routing. Local health reacts first, and discovery membership turns that temporary decision into shared ring membership on the next rebuild.</p><h3>Conclusion</h3><p>Ring-based client-side load balancing comes down to a repeatable routing flow. The client hashes a stable request value, moves clockwise to the matching backend point, and keeps sending that traffic to the same place while membership stays unchanged. Virtual points spread ownership across the ring more evenly, service discovery keeps the active backend set current, and local health checks let the client step past failing nodes before the next refresh arrives. When a backend drops out or a new one joins, only the affected slice of ring ownership is reassigned, which is why this model fits sticky routing, shard-aware traffic, and cache-heavy traffic so well.</p><ol><li><p><em><a href="https://docs.spring.io/spring-cloud-commons/reference/spring-cloud-commons/loadbalancer.html">Spring Cloud LoadBalancer Reference</a></em></p></li><li><p><em><a href="https://kubernetes.io/docs/concepts/services-networking/service/">Kubernetes Service Documentation</a></em></p></li><li><p><em><a href="https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/">Kubernetes EndpointSlices Documentation</a></em></p></li><li><p><em><a href="https://developer.hashicorp.com/consul/docs/register/health-check/">Consul Health Checks Documentation</a></em></p></li><li><p><em><a href="https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/load_balancers">Envoy Ring Hash Load Balancing</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-cloud-commons/reference/">Spring Cloud Commons Reference</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!uqo1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!uqo1!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!uqo1!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!uqo1!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!uqo1!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!uqo1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!uqo1!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!uqo1!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!uqo1!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!uqo1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F63bfbde9-c605-465e-a7cf-cb6a52cddc4d_276x276.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://icons8.com/icon/90519/spring-boot">Spring Boot</a> icon by <a href="https://icons8.com/">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Kosaraju vs. Tarjan in Java]]></title><description><![CDATA[Strongly connected components split a directed graph into maximal groups where every vertex can reach every other vertex in the same group.]]></description><link>https://alexanderobregon.substack.com/p/kosaraju-vs-tarjan-in-java</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/kosaraju-vs-tarjan-in-java</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Fri, 03 Apr 2026 17:17:46 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!-Ei8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!-Ei8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!-Ei8!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!-Ei8!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!-Ei8!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!-Ei8!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!-Ei8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!-Ei8!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!-Ei8!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!-Ei8!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!-Ei8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F327852db-dccb-426f-98aa-66397c4ba3bb_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Strongly connected components split a directed graph into maximal groups where every vertex can reach every other vertex in the same group. Two classic depth first search methods for finding those groups are the Kosaraju Sharir method and Tarjan&#8217;s method. They both run in linear time on an adjacency-list graph, yet they separate components in very different ways. Kosaraju records finish order, builds a reversed graph, then runs a second pass. Tarjan stays on the original graph, keeps an active vertex stack, and compares discovery indexes with low-link values to decide the exact moment a component is complete. In Java, that difference changes adjacency storage, stack handling, object allocation, and extra memory kept during traversal.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>Kosaraju in Java</h3><p>Two full depth first search passes drive this method. The first pass walks the graph in its original direction and records vertices by exit time. The second pass walks a transposed copy of the graph in reverse exit order and pulls out one strongly connected component at a time. Sharir&#8217;s 1981 paper gives the linear-time foundation for this family of SCC algorithms, and the form most learn today still follows that same two-pass structure on adjacency-list graphs. What makes Kosaraju readable in Java is the way those two passes stay separate in purpose. The first pass has a single job, it captures finish order. The second pass has a different job. It follows reversed edges and gathers component members. That split keeps the code easy to scan because the first DFS is not trying to assign component ids while it is still walking outward from a vertex. Storage does grow, though. You keep the original adjacency list, build a reversed copy, track visited state, and hold an order container from the first pass.</p><h4>Finish Order</h4><p>Exit time is the first idea that needs to lock into place. During DFS, a vertex is not recorded when it is first reached. Recording happens only after DFS has followed every reachable outgoing edge from that vertex and returned all the way back. That stored order is what the second pass depends on. If one strongly connected component can reach a different component in the condensation graph, the source component finishes later than the destination component. That finish timing is what makes the later reverse-order scan meaningful instead of random.</p><p>Viewed through the recursion stack, the first pass keeps delaying a vertex until every deeper call below it is finished. Whole components inherit that timing. If a component has an edge that leads outward into a different component, DFS can keep moving before it finally comes back to the starting side. That outward reach pushes the source component later in the finish sequence. When the stored order is read back from the front of a stack-like container, those later-finishing components come out first.</p><p>Most Java versions keep the first pass compact:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;fdd7aad9-0516-4979-9c2b-e1958687e98d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private void fillOrder(int node, boolean[] visited, List&lt;List&lt;Integer&gt;&gt; graph, Deque&lt;Integer&gt; order) {
    visited[node] = true;

    for (int next : graph.get(node)) {
        if (!visited[next]) {
            fillOrder(next, visited, graph, order);
        }
    }

    order.push(node);
}</code></pre></div><p>Placement of <code>order.push(node)</code> is the whole story in that method. It belongs after the neighbor loop, not before it. Move it to the top and the stored order turns into visit order instead of finish order. Put it inside the loop and the recorded sequence no longer matches DFS exit time. That single line is small in the code and very large in effect.</p><p>Visit order and finish order are easy to blur on a first read, so it helps to separate them very deliberately. Visit order tells you when DFS first touches a vertex. Finish order tells you when DFS is done with that vertex after every reachable outgoing branch has already been handled. Kosaraju depends on the second of those two timings, not the first. Two vertices can be visited in one order and finished in the reverse order, and that reversal is perfectly normal.</p><p>For stack-like storage, <code>Deque</code> backed by <code>ArrayDeque</code> is the better fit in modern Java. The library documentation treats <code>Stack</code> as legacy and points readers toward <code>Deque</code> for LIFO behavior. In practice, the mapping is exactly what this pass needs. <code>push</code> places a vertex at the front, <code>pop</code> removes from the front, and <code>peek</code> reads the next vertex that would come off.</p><p>That behavior is easy to see in this example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;56081403-608d-40c9-9d33-c4619d113a39&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">Deque&lt;Integer&gt; order = new ArrayDeque&lt;&gt;();

order.push(7);
order.push(3);

int firstOut = order.pop();   // 3
int nextOut = order.peek();   // 7</code></pre></div><p>That same LIFO behavior is what the first pass relies on. Vertices that finish later are pushed later, so they rise to the top of the deque and come off first during the second pass. Nothing fancy is happening there. The container is just preserving the exact order that the second sweep needs.</p><p>Tracing a DFS run can also make the finish-order rule much easier to read while testing a graph:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c7fede61-617a-4f4a-be35-23172ea64ff6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private void fillOrderTrace(
        int node,
        boolean[] visited,
        List&lt;List&lt;Integer&gt;&gt; graph,
        Deque&lt;Integer&gt; order
) {
    visited[node] = true;
    System.out.println("enter " + node);

    for (int next : graph.get(node)) {
        if (!visited[next]) {
            fillOrderTrace(next, visited, graph, order);
        }
    }

    order.push(node);
    System.out.println("exit  " + node + " push to order");
}</code></pre></div><p>Those print lines tell two different stories. The <code>enter</code> line gives visit order. The <code>exit</code> line gives the order Kosaraju actually keeps. On a graph with one region leading into another, the gap between those two views becomes very visible. That gap is not a side detail. The algorithm depends on it.</p><h4>Reverse Graph Pass</h4><p>Every edge is flipped before the second DFS pass begins. If the original graph contains <code>u -&gt; v</code>, the transpose stores <code>v -&gt; u</code>. Strongly connected components themselves do not change under that reversal. Internal mutual reachability inside a component still holds after every edge is reversed. What does change is the direction of travel between components in the condensation graph. A source component in the original condensation graph becomes a sink in the transposed graph, and that is exactly what the second pass needs when it starts from the vertex with the largest recorded exit time.</p><p>That is the reason this method takes two passes. After the first pass has ordered vertices by decreasing exit time, the second pass can start from the front of that order and walk only within the reversed edges of the intended component. The transpose does not create new SCCs or destroy old ones. It changes the between-component direction so the next DFS stops at the correct boundary.</p><p>Building the transposed graph in Java is usually one full scan over the original adjacency list:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;46ef4911-cb14-4f92-be38-398d828691e3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private static List&lt;List&lt;Integer&gt;&gt; transpose(List&lt;List&lt;Integer&gt;&gt; graph) {
    List&lt;List&lt;Integer&gt;&gt; reversed = new ArrayList&lt;&gt;(graph.size());

    for (int i = 0; i &lt; graph.size(); i++) {
        reversed.add(new ArrayList&lt;&gt;());
    }

    for (int from = 0; from &lt; graph.size(); from++) {
        for (int to : graph.get(from)) {
            reversed.get(to).add(from);
        }
    }

    return reversed;
}</code></pre></div><p>That method allocates a new outer list with the same vertex count, then scans every original edge. For every <code>from -&gt; to</code>, it appends <code>from</code> into the neighbor list of <code>to</code>. Runtime stays linear in vertices and edges on adjacency-list graphs, but memory grows because the graph is now stored twice, first in its original direction and then in reversed form. That extra edge storage is one of the main tradeoffs tied to Kosaraju in Java.</p><p>After the transpose is ready, the second pass is mechanically simple relatively. Pop the next vertex from the finish-order deque. If that vertex has not been visited in the second pass yet, launch DFS on the transposed graph from that vertex. Every vertex reached during that one search belongs to the same strongly connected component. No extra test is needed to discover the boundary during the walk. The ordering from the first pass and the reversed edges already did that job before the second DFS even started.</p><p>That keeps the second pass easier to read than readers sometimes expect. After the ordering is correct and the transpose exists, this pass is just DFS on a different adjacency list. What looked abstract during the first explanation becomes very mechanical in code. Start from the next recorded vertex, follow reversed edges, mark visited vertices, collect them into the current component, then move on to the next unvisited vertex in the stored order.</p><p>Recursion depth is still worth keeping in mind in Java. DFS reads nicely in recursive form, but a very deep graph can still overflow the thread stack. The algorithm itself does not require recursion. It requires depth-first traversal and finish-order recording. If graph depth is large enough to make stack depth a concern, an iterative translation with an explicit frame stack can preserve the same logic while avoiding recursive calls.</p><h3>Tarjan in Java</h3><p>Single-pass SCC detection is what sets Tarjan apart from Kosaraju. Instead of recording finish order, building a transposed graph, and returning for a second sweep, Tarjan stays on the original directed graph and decides component boundaries during the same DFS that first discovers the vertices. That difference changes the feel of the code right away. More live state has to travel through the traversal, but the graph itself stays in a single direction. Compared with Kosaraju, the trade is easy to see. Kosaraju keeps the DFS logic lighter in each pass and pays for that with a reversed graph and another traversal. Tarjan keeps everything inside one traversal and pays for that with more per-vertex bookkeeping. In Java, that bookkeeping usually lives in a few primitive arrays and one stack-like container, which makes the method compact in memory while still asking the reader to track more moving pieces during DFS.</p><h4>Discovery Index</h4><p>Every vertex gets a discovery index at the moment DFS reaches it for the first time. That number is assigned from a running counter and never changes later. Most Java versions store those values in an <code>int[] index</code> array, and unseen vertices start at <code>-1</code>. Tarjan also sets <code>low[v]</code> to that same index at first contact, because the vertex can always reach itself before any outgoing edge is checked.</p><p>That first assignment block is small, but it carries a lot of weight. Kosaraju spends its opening pass building finish order for later use. Tarjan starts attaching stable timestamps right away, and those timestamps become the anchor for every low-link update that follows. If <code>index[v]</code> is the moment the search entered <code>v</code>, then every later question about how far back <code>v</code> can still reach gets measured against that fixed number.</p><p>The first-visit code usually reads like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c877809a-4dde-4c56-8201-c1d9e1620567&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private void dfs(int node) {
    index[node] = time;
    low[node] = time;
    time++;

    stack.push(node);
    onStack[node] = true;

    for (int next : graph.get(node)) {
        if (index[next] == -1) {
            dfs(next);
            low[node] = Math.min(low[node], low[next]);
        } else if (onStack[next]) {
            low[node] = Math.min(low[node], index[next]);
        }
    }

    if (low[node] == index[node]) {
        popComponent(node);
    }
}</code></pre></div><p>Two arrays are being given a starting value in those first three lines, and that is deliberate. <code>index[node]</code> records entry time. <code>low[node]</code> starts with the same value because DFS has not seen any edge yet that proves a path back to an earlier discovered vertex. From there, every child return and every back edge can pull <code>low[node]</code> downward, but <code>index[node]</code> itself stays fixed.</p><p>Initialization often looks like this in Java:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;65dfb041-c549-4d38-b0ae-d5227bbc7633&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public TarjanScc(List&lt;List&lt;Integer&gt;&gt; graph) {
    this.graph = graph;
    int n = graph.size();
    this.index = new int[n];
    this.low = new int[n];
    this.onStack = new boolean[n];
    this.stack = new ArrayDeque&lt;&gt;();
    this.components = new ArrayList&lt;&gt;();
    Arrays.fill(index, -1);
}</code></pre></div><p>Filling <code>index</code> with <code>-1</code> gives the DFS a cheap unseen marker. Primitive arrays help here because they avoid per-vertex object overhead. Compared with Kosaraju, that is one of the first places the memory trade starts to show up. Tarjan spends memory on state arrays tied to vertices, while Kosaraju spends more memory on edge storage after the transpose is built.</p><h4>Low Link Values</h4><p>Low-link values are the part that usually takes the longest to settle in because they are not just visit timestamps with a new name. For a vertex <code>v</code>, <code>low[v]</code> tracks the smallest discovery index reachable from <code>v</code> while the DFS is still inside the current unfinished region. That route can move downward through DFS tree edges and can also use a back edge to an earlier discovered vertex that is still active on the stack.</p><p>Seen that way, <code>low[v]</code> answers a very specific question. From <code>v</code>, how far back into the current DFS history can the search still reach without leaving the unfinished region. If the answer is smaller than <code>index[v]</code>, then <code>v</code> is tied to an earlier ancestor in the same still-open component. If the answer stays equal to <code>index[v]</code>, then no vertex below <code>v</code> managed to reach an earlier active ancestor than <code>v</code> itself.</p><p>Two update cases drive the whole idea. First, DFS can walk from <code>v</code> into an unseen child <code>w</code>. After that recursive call returns, whatever earliest active ancestor <code>w</code> could reach is now also reachable from <code>v</code>, so <code>low[v]</code> gets compared against <code>low[w]</code>. Second, DFS can see an edge from <code>v</code> to a vertex that was already discovered and is still on the active stack. In that case, <code>v</code> has found a direct route back into the live DFS chain, so <code>low[v]</code> gets compared against <code>index</code> of that stack vertex.</p><p>That logic is usually written in one tight block:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8f235503-a552-404c-a743-7ea956c8780b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">for (int next : graph.get(node)) {
    if (index[next] == -1) {
        dfs(next);
        low[node] = Math.min(low[node], low[next]);
    } else if (onStack[next]) {
        low[node] = Math.min(low[node], index[next]);
    }
}</code></pre></div><p>The difference between those two updates is what keeps the numbers honest. Child returns use <code>low[next]</code> because the child subtree may have reached farther back than its own entry time. Edges to a still-active vertex use <code>index[next]</code> because that edge is tying the current node directly to a known earlier point in the live stack. Swapping those two rules around breaks the logic.</p><p>Let&#8217;s see how a short tracing helper can make the values easier to follow while stepping through a graph:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;24c96e3c-e9fa-49e1-8b0e-5bf5b2a47900&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private void dfsTrace(int node) {
    index[node] = time;
    low[node] = time;
    time++;

    stack.push(node);
    onStack[node] = true;

    System.out.println("visit " + node + " index=" + index[node] + " low=" + low[node]);

    for (int next : graph.get(node)) {
        if (index[next] == -1) {
            dfsTrace(next);
            low[node] = Math.min(low[node], low[next]);
            System.out.println("return to " + node + " from " + next + " low=" + low[node]);
        } else if (onStack[next]) {
            low[node] = Math.min(low[node], index[next]);
            System.out.println("back edge " + node + " -&gt; " + next + " low=" + low[node]);
        }
    }

    if (low[node] == index[node]) {
        popComponent(node);
    }
}</code></pre></div><p>Those trace lines help separate child-return updates from back-edge updates, which is usually where readers first get tangled up. After a child returns, the parent absorbs the child&#8217;s best reach into the active DFS chain. During a back edge, the current vertex reaches directly into that chain itself. Both cases can reduce <code>low</code>, but they do it for different reasons.</p><p>Kosaraju handles component boundaries through finish order and a later scan on reversed edges. Tarjan pushes that boundary logic into the current DFS frame instead. Low-link values are the mechanism that makes that possible. They let the method decide, during unwinding, if a vertex is still tied to an earlier active ancestor or if the search has closed a full component at the current vertex.</p><h4>Active Stack Rule</h4><p>Stack membership is the guardrail that keeps Tarjan from merging vertices that no longer belong to the same unfinished region. During DFS, every newly discovered vertex is pushed onto the active stack and marked in <code>onStack</code>. That mark stays true until the algorithm has proven the root of its component and pops the whole group. Vertices that have already been popped are finished. Edges to them do not pull low-link values backward anymore. That condition is why Tarjan does not treat every visited vertex the same way. Any discovered vertex that is still on the stack can affect <code>low-link</code> through a back edge. Any discovered vertex that has already been popped cannot. This marks a sharp difference from a plain visited-array DFS. Tarjan needs two states after discovery, not just visited and unvisited. It needs active and finished.</p><p>The root test is very precise because if <code>low[node] == index[node]</code>, then the search has found the vertex that closes an SCC. That equality means no vertex reachable below <code>node</code> found a route back to any earlier active ancestor than <code>node</code>. At that moment, vertices are popped from the stack until <code>node</code> itself comes off, and every popped vertex belongs to that component.</p><p>That pop block usually looks something like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;eec2b0ce-580d-4b07-9de2-cb0b33c00e93&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private void popComponent(int root) {
    List&lt;Integer&gt; component = new ArrayList&lt;&gt;();

    while (true) {
        int top = stack.pop();
        onStack[top] = false;
        component.add(top);

        if (top == root) {
            break;
        }
    }

    components.add(component);
}</code></pre></div><p>The stack is doing more than storing DFS history there. It is storing the current unfinished region from which SCCs can still be formed. After a vertex leaves that stack, it is done. Future edges pointing to it no longer count as live links inside the current region. That is why the neighbor check uses <code>onStack[next]</code> instead of a plain visited test.</p><p>Compared with Kosaraju, this is where the boundary decision moves. Kosaraju waits until a later pass to gather SCC members. Tarjan gathers them the instant a root condition becomes true during the first traversal. The active stack is what makes that immediate decision safe.</p><h4>Java Memory Considerations</h4><p>Memory tradeoffs in Java are one of the easiest ways to separate Tarjan from Kosaraju. Tarjan stays on the original adjacency list and avoids building a transposed graph, so there is no second full copy of the edge structure. In return, it keeps more live per-vertex state while DFS is running. Typical storage is an <code>int[] index</code>, an <code>int[] low</code>, a <code>boolean[] onStack</code>, and a <code>Deque&lt;Integer&gt;</code> for the active vertex stack, along with the adjacency list itself and the output collection for components.</p><p>On adjacency-list graphs, runtime still stays at <code>O(V + E)</code>. Space also stays linear, but the layout is different from Kosaraju&#8217;s layout. Tarjan pushes memory toward vertex bookkeeping. Kosaraju pushes memory toward duplicated edge storage after the transpose is built. That difference can stand out on edge-heavy graphs, where a second adjacency structure takes more room than a few primitive arrays.</p><p>For the stack container, <code>Deque&lt;Integer&gt;</code> backed by <code>ArrayDeque</code> is the better modern fit in Java. The older <code>Stack</code> class is synchronized and tied to <code>Vector</code>, which adds behavior Tarjan does not need for a normal single-threaded graph traversal. <code>ArrayDeque</code> gives <code>push</code>, <code>pop</code>, and <code>peek</code> directly, which lines up nicely with the active-stack rule.</p><p>Recursion depth still deserves a place in the discussion. Tarjan is usually presented recursively because the code mirrors the theory very closely that way. Deep directed graphs can still drive the call stack high enough to throw <code>StackOverflowError</code> in Java. If graph depth is a concern, an iterative DFS translation can keep the same algorithmic logic while moving call-state storage into an explicit frame stack. The trade then moves from JVM call-stack usage to a larger amount of manual bookkeeping in the code.</p><p>From a Java-reading angle, Tarjan asks more from the person reading it than Kosaraju does. Finish order and graph transposition are very visible ideas. Discovery indexes, low-link values, and active-stack membership take more patience the first time through. The payoff is that the graph never has to be rebuilt in reversed form, and SCCs are emitted during the same DFS that first discovers the vertices.</p><h3>Conclusion</h3><p>Kosaraju and Tarjan solve the same SCC problem, but the internal logic that gets them there is very different. Kosaraju splits the job into finish order first and component collection later on the reversed graph, while Tarjan keeps everything inside one DFS by tracking discovery indexes, low-link values, and the active stack at the exact moment a component closes. Put side by side in Java, that contrast makes the trade easy to see. Kosaraju spends more memory on a second adjacency structure, while Tarjan spends more thought on live traversal state.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/Deque.html">Java </a></em><code>Deque</code><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/Deque.html"> Interface Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/ArrayDeque.html">Java </a></em><code>ArrayDeque</code><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/ArrayDeque.html"> Class Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/Stack.html">Java </a></em><code>Stack</code><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/Stack.html"> Class Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/List.html">Java </a></em><code>List</code><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/List.html"> Interface Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/ArrayList.html">Java </a></em><code>ArrayList</code><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/ArrayList.html"> Class Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/Arrays.html">Java </a></em><code>Arrays</code><em><a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/util/Arrays.html"> Class Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/javase/8/docs/api/java/lang/StackOverflowError.html">Java </a></em><code>StackOverflowError</code><em><a href="https://docs.oracle.com/javase/8/docs/api/java/lang/StackOverflowError.html"> Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Deduplication and Idempotency with Hashing and Expiring Sets in Spring Boot]]></title><description><![CDATA[Spring Boot gives you a current Redis stack through spring-boot-starter-data-redis, so request fingerprinting and short-lived duplicate tracking can stay close to tools people already use every day.]]></description><link>https://alexanderobregon.substack.com/p/deduplication-and-idempotency-with</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/deduplication-and-idempotency-with</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Mon, 30 Mar 2026 17:36:02 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/4207ea9d-9116-41ff-89a2-8d327f9a926c_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!qRgw!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!qRgw!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!qRgw!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!qRgw!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!qRgw!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!qRgw!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!qRgw!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!qRgw!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!qRgw!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!qRgw!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37ec7ea9-684c-413b-8fe8-d6847421ee85_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://spring.io/projects/spring-boot">Image Source</a></figcaption></figure></div><p>Spring Boot gives you a current Redis stack through <code>spring-boot-starter-data-redis</code>, so request fingerprinting and short-lived duplicate tracking can stay close to tools people already use every day. <code>RedisTemplate</code>, <code>StringRedisTemplate</code>, TTL-based cache handling, and <code>Caffeine</code> all fit nicely in that process, which makes deduplication and idempotency feel less abstract and more like a question of how a request gets identified, stored for a limited window, and checked before the write happens.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>Request Identity in a Spring Boot Flow</h3><p>Before a request can be blocked as a duplicate or treated as the same business action, the application has to decide what sameness means. That sounds small at first, but this is where the whole flow gets its meaning. Two HTTP requests can arrive with different headers, different trace ids, or a slightly different JSON field order and still be the same request in business terms. Spring Boot does not decide that for you. Your code does, and that decision needs to happen before any short-lived memory store gets involved.</p><h4>Deduplication Versus Idempotency</h4><p>Duplicate requests show up in ordinary traffic for ordinary reasons. Browser form submissions can fire twice. Mobile clients retry after a timeout. Gateways or load balancers can replay a request after a broken connection. From the server side, all of those can look like valid incoming calls, so the application needs a way to tell a fresh request from a repeat.</p><p>Deduplication usually means blocking a repeated request inside a small time window. That can be enough when the goal is just to stop accidental double submission. Think about a checkout button tapped twice in quick succession. If the server spots the second request fast enough, it can stop the duplicate before a second write happens.</p><p>Idempotency goes further than that. Instead of just saying this one was already seen recently, it treats repeated delivery of the same logical action as one action. That distinction changes the server behavior. A seen marker can block a second database insert, but fuller idempotency usually keeps enough state to connect the retry back to the first result. That way the client does not just get a rejection. It can get the same outcome tied to the first successful request.</p><p>Another way to put it is that deduplication is about suppression, while idempotency is about identity. Suppression says do not run that again right now. Identity says this retry belongs to an operation that already happened, so keep the outcome tied to that operation instead of creating a new one. Payment APIs, order creation endpoints, and external callback handlers usually need that second meaning more than the first.</p><p>Spring MVC gives you a natural place to read the incoming request details that may define that identity. One common input is an idempotency key sent by the client in a header. That header by itself is not the full story, but it is a strong starting point because it gives the client a stable token for the operation it is retrying.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;526d26d1-bd83-4911-9ab0-19a2ebdbce6f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;

@Component
public class IdempotencyKeyReader {

    public String readKey(HttpServletRequest request) {
        String value = request.getHeader("Idempotency-Key");
        if (value == null) {
            return "";
        }
        return value.trim();
    }
}</code></pre></div><p>That code only reads a header, but the point is larger than the method itself. The application is pulling out one field that can help name the operation across retries. A client can send the same <code>Idempotency-Key</code> again after a timeout, and the server can treat the retry as tied to the same business action rather than as a brand-new request.</p><p>Headers are not enough by themselves for every endpoint. Some routes need business fields in the identity too. A payment retry with the same header but a different amount should not silently count as the same operation. An order submit tied to a different cart or account should not reuse the first request&#8217;s identity either. That is why idempotency identity normally comes from a small group of fields rather than one transport detail in isolation.</p><h4>Building a Stable Fingerprint</h4><p>Request fingerprinting starts before hashing. Hashing only compresses the identity string you hand to it. If the input changes every time, the digest changes every time too, and duplicate detection falls apart. Stable fingerprinting begins with a canonical view of the request, where the application picks the fields that define the operation, normalizes them, and writes them in a repeatable order.</p><p>Good inputs come from the fields that actually define the action. Route, HTTP method, tenant or account id, an external request id, amount, and currency are common choices for a payment-style endpoint. Temporary values do not belong there. Trace ids, current timestamps, random request metadata, or raw JSON text with changing whitespace can turn the same logical action into different fingerprints by accident.</p><p>Field normalization keeps harmless formatting differences from changing the identity, while methods are usually folded to uppercase. Currency codes are better compared in one case. Paths can be normalized so doubled slashes do not create a different result. Numeric values need care too. <code>BigDecimal</code> amounts written as <code>10.0</code> and <code>10.00</code> usually mean the same business value, so both should collapse to the same canonical representation before hashing.</p><p>Let&#8217;s see what that normalization step looks like in code:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8ed9ef0f-6b6a-417c-9bf0-e4649ee09dcf&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import java.math.BigDecimal;
import java.util.Locale;

import org.springframework.stereotype.Component;

@Component
public class FingerprintCanonicalizer {

    public String canonicalize(PaymentRequest request, String method, String path, String idempotencyKey) {
        return String.join("\n",
                normalize(request.tenantId()),
                normalize(request.accountId()),
                normalizeMethod(method),
                normalizePath(path),
                normalize(idempotencyKey),
                normalizeAmount(request.amount()),
                normalizeCurrency(request.currency())
        );
    }

    private String normalize(String value) {
        return value == null ? "" : value.trim();
    }

    private String normalizeMethod(String value) {
        return normalize(value).toUpperCase(Locale.US);
    }

    private String normalizePath(String value) {
        return normalize(value).replaceAll("/+", "/");
    }

    private String normalizeCurrency(String value) {
        return normalize(value).toUpperCase(Locale.US);
    }

    private String normalizeAmount(BigDecimal value) {
        if (value == null) {
            return "";
        }
        return value.stripTrailingZeros().toPlainString();
    }
}</code></pre></div><p>That canonical string is the part worth paying attention to. Newlines are only separators. The real value comes from turning the request into one repeatable text form. If the same logical request comes back after a retry, the canonical text should come out exactly the same. If the client changes a business field that actually changes the operation, the canonical text should change too.</p><p><code>SHA-256</code> is a good default digest for this job in Java because <code>MessageDigest</code> supports it as a standard algorithm, and <code>UTF-8</code> gives a stable byte representation for the canonical text. The digest is not replacing your business identity. It is turning that identity into a compact token that is easy to store and compare.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;a9db22c6-130f-42b8-a334-c9843e4a52c5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;

import org.springframework.stereotype.Component;

@Component
public class RequestFingerprintService {

    public String fingerprint(String canonicalRequest) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(canonicalRequest.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hash);
        } catch (NoSuchAlgorithmException ex) {
            throw new IllegalStateException("SHA-256 is not available", ex);
        }
    }
}</code></pre></div><p>Notice what this service does not do. It does not read the servlet request directly, it does not pick which fields count, and it does not try to infer business meaning from a raw body. That separation keeps the fingerprinting logic easier to read. One small area of code decides identity. Another turns that identity into a digest.</p><p>Request bodies deserve special care here. Raw JSON text is a poor fingerprint source when taken as-is, because field order and spacing can differ across clients without changing the data. Parsing the body into a request object and then selecting the business fields gives a more stable result than hashing raw text. Spring Boot already maps JSON to Java records or classes through <code>@RequestBody</code>, which makes this step fit naturally into a controller flow.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;246948de-2b65-4746-b25e-8eb802e5f2f8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import java.math.BigDecimal;

public record PaymentRequest(
        String tenantId,
        String accountId,
        BigDecimal amount,
        String currency
) {}</code></pre></div><p>A controller can then assemble the identity from the mapped request plus request metadata that actually belongs in the fingerprint:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;35af500d-2f30-48e2-a567-1577d4010475&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/payments")
public class PaymentController {

    private final IdempotencyKeyReader keyReader;
    private final FingerprintCanonicalizer canonicalizer;
    private final RequestFingerprintService fingerprintService;

    public PaymentController(
            IdempotencyKeyReader keyReader,
            FingerprintCanonicalizer canonicalizer,
            RequestFingerprintService fingerprintService
    ) {
        this.keyReader = keyReader;
        this.canonicalizer = canonicalizer;
        this.fingerprintService = fingerprintService;
    }

    @PostMapping
    public ResponseEntity&lt;String&gt; createPayment(
            @RequestBody PaymentRequest request,
            HttpServletRequest httpRequest
    ) {
        String idempotencyKey = keyReader.readKey(httpRequest);

        String canonical = canonicalizer.canonicalize(
                request,
                httpRequest.getMethod(),
                httpRequest.getRequestURI(),
                idempotencyKey
        );

        String fingerprint = fingerprintService.fingerprint(canonical);

        return ResponseEntity.ok(fingerprint);
    }
}</code></pre></div><p>That controller is not doing duplicate checks yet. It is doing the earlier job, which is giving the request an identity that survives retries. Same tenant, same account, same route, same operation id, same amount, same currency, same method should lead to the same fingerprint. Different business meaning should lead to a different one.</p><p>Good request identity starts with plain questions. What fields make this operation the same operation on a retry. Which transport details can change without changing the business action. Which values need normalization before comparison. After those answers are settled, hashing becomes the compact final step rather than the place where the logic is supposed to come from.</p><h3>Time Bound Request Memory</h3><p>Duplicate checks need some place to remember what was seen a moment ago. After the fingerprint has been built, the next question is where that fingerprint should live and how long it should stay there. Spring Boot gives you room for both shared storage through Redis and process-local storage through <code>Caffeine</code>, and the right pick depends on scope, retry behavior, and how exact the expiration window needs to be. Redis expiration is attached to keys, Spring Data Redis exposes write calls that can claim a key with a timeout in one step, and Spring Boot can auto-configure a <code>CaffeineCacheManager</code> when Caffeine is present.</p><h4>Redis String Keys with TTL</h4><p>Shared request memory across several app instances is usually easiest to reason through when each fingerprint gets its own Redis string key. That model maps well to duplicate detection because the request either claims the fingerprint first or finds that someone else already claimed it. Redis <code>SET</code> supports <code>NX</code> so the write only happens when the key does not exist, and the same command also accepts expiration options on that key. Spring Data Redis mirrors that form through <code>ValueOperations.setIfAbsent(key, value, Duration timeout)</code>, which writes only on absence and applies the timeout in the same call.</p><p>That flow keeps the decision generally small and direct. Fresh fingerprints create short-lived Redis keys. Retries that arrive during the active TTL window see the existing key and fail the claim.Nothing about that requires a large object model. Marker values like <code>1</code>, a request id, or a short status token are enough for the first gate, and fuller result state can live elsewhere if the endpoint needs reply replay instead of a plain duplicate rejection. Redis <code>TTL</code> can then report how much life the key still has if you need visibility during debugging or metrics gathering.</p><p>Now, let&#8217;s look at what that write step can look like in Spring code:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;3b4f1bd4-3e15-49fb-8079-6223747046b8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import java.time.Duration;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class RedisFingerprintWindow {

    private final StringRedisTemplate redisTemplate;

    public RedisFingerprintWindow(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public boolean claim(String fingerprint, Duration ttl) {
        String redisKey = "idem:" + fingerprint;
        Boolean created = redisTemplate.opsForValue()
                .setIfAbsent(redisKey, "1", ttl);

        return Boolean.TRUE.equals(created);
    }
}</code></pre></div><p><code>claim</code> returns <code>true</code> only for the first caller that writes the fingerprint during that TTL window. Later retries hit the same Redis key and get <code>false</code>, which is exactly the branch a duplicate check needs.</p><p>Fresh code should stay with <code>SET</code> plus expiration options rather than older string commands that baked expiration into a separate command name. Redis marks <code>SETEX</code> as deprecated and points new code toward <code>SET</code> with <code>EX</code>. That lines up well with Spring Data Redis, because <code>setIfAbsent</code> with a <code>Duration</code> already follows the newer direction instead of splitting the write into a plain set followed by a second expire call. Fixed TTL also needs to be kept separate from read-refreshed lifetime. Redis expiration is TTL by default, which means the clock starts when the key is written. Spring Data Redis cache support can emulate a time-to-idle style read refresh through <code>GETEX</code>, and that depends on Redis <code>GETEX</code> support. Duplicate request memory usually fits fixed TTL better than read-refreshed lifetime, because a retry should not keep extending the duplicate block unless you chose that behavior on purpose.</p><h4>Redis Set Buckets by Time Slice</h4><p>Sets can still do duplicate tracking, but the storage shape changes a little. Redis <code>SADD</code> adds a member to a set and ignores a repeat add for the same member, while <code>SISMEMBER</code> checks membership. Those commands are useful for fingerprint tracking, yet expiration in Redis belongs to the whole key, not to one member inside the key. <code>EXPIRE</code> sets a timeout on the key itself, so a plain set does not give every fingerprint its own private countdown.</p><p>That behavior leads to bucketed sets. Instead of storing every fingerprint in one long-lived set, the application writes each fingerprint into a bucket key tied to a short time slice such as a minute. The bucket key can then expire as a unit after the full detection window has passed. A request arriving at 9:14 could be written to <code>idem:bucket:2026-03-30T09:14</code>, while a retry check looks at the current bucket and perhaps the immediately previous bucket if the window spans more than one slice. Expiration stays cheap because Redis deletes the whole bucket key when its timeout runs out. <code>SADD</code> and <code>SISMEMBER</code> remain useful, but the time window belongs to the bucket, not to the individual fingerprint.</p><p>Code for a bucket name helper is short, though the naming choice affects how broad the window becomes near the edge of a minute:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e583db71-8ea6-4c76-aa57-9df1122064ff&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

import org.springframework.stereotype.Component;

@Component
public class FingerprintBucketNamer {

    private static final DateTimeFormatter MINUTE_FORMAT =
            DateTimeFormatter.ofPattern("yyyyMMddHHmm").withZone(ZoneOffset.UTC);

    public String currentBucket(Instant now) {
        return "idem:bucket:" + MINUTE_FORMAT.format(now);
    }
}</code></pre></div><p>That minute bucket name can then be paired with a set add and a key expiration:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;36839c3c-6900-46b6-8998-ed80c1e2f38e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import java.time.Duration;
import java.time.Instant;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class RedisBucketWindow {

    private final StringRedisTemplate redisTemplate;
    private final FingerprintBucketNamer bucketNamer;

    public RedisBucketWindow(StringRedisTemplate redisTemplate,
                             FingerprintBucketNamer bucketNamer) {
        this.redisTemplate = redisTemplate;
        this.bucketNamer = bucketNamer;
    }

    public boolean markSeen(String fingerprint, Instant now, Duration bucketTtl) {
        String bucketKey = bucketNamer.currentBucket(now);
        Long added = redisTemplate.opsForSet().add(bucketKey, fingerprint);
        redisTemplate.expire(bucketKey, bucketTtl);
        return added != null &amp;&amp; added &gt; 0;
    }
}</code></pre></div><p>That write style is good for grouped windows and bulk cleanup, though it is less exact than a per-fingerprint string key. A fingerprint written near the start of a minute and one written near the end of the same minute share the same bucket lifetime unless you split the time slices more finely. Bucketed sets fit best when a short approximate window is fine and grouped expiry is acceptable.</p><h4>Local Memory with Caffeine</h4><p>Process-local duplicate memory is a good fit when traffic is routed to one JVM or when the duplicate window only needs to cover repeated calls that are likely to hit the same node. Spring Boot can auto-configure a <code>CaffeineCacheManager</code> when Caffeine is present, and Caffeine supports write-based expiration through <code>expireAfterWrite</code>. Caffeine also exposes a thread-safe map view through <code>asMap()</code>, which gives direct access to map methods such as <code>putIfAbsent</code>. That local path removes the Redis round trip and keeps duplicate memory inside the process. It also changes the scope. One instance only knows what that instance has seen. If two retries hit different pods, node-local memory has no shared view, so both requests can still pass their local checks. That tradeoff makes local memory useful for single-instance deployments, local development, scheduled jobs pinned to one node, or routes where near-term suppression on the same JVM is enough. Spring Boot&#8217;s cache docs and Caffeine&#8217;s own expiration support make that model very natural to build.</p><p>Small cache beans can hold that short-lived membership map:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;acfaf217-a314-4ed8-9d90-0026ea14824a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import java.time.Duration;
import java.time.Instant;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class LocalFingerprintCacheConfig {

    @Bean
    Cache&lt;String, Instant&gt; fingerprintCache() {
        return Caffeine.newBuilder()
                .expireAfterWrite(Duration.ofMinutes(10))
                .maximumSize(200_000)
                .build();
    }
}</code></pre></div><p>That cache can then back a first-write-wins check through the map view:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c793d816-8154-4810-97aa-2ff8764bd080&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.idempotency;

import java.time.Instant;

import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.stereotype.Service;

@Service
public class LocalFingerprintWindow {

    private final Cache&lt;String, Instant&gt; cache;

    public LocalFingerprintWindow(Cache&lt;String, Instant&gt; cache) {
        this.cache = cache;
    }

    public boolean claim(String fingerprint) {
        return cache.asMap().putIfAbsent(fingerprint, Instant.now()) == null;
    }
}</code></pre></div><p><code>putIfAbsent</code> gives a compact duplicate gate. The first caller inserts the timestamp marker. Later callers during the active write-expiration window see the prior value and fail the claim. The value could be a timestamp, a request id, or a tiny marker object. The actual duplicate memory lives in the cache entry itself, while <code>expireAfterWrite</code> controls how long that memory stays alive. Caffeine removes entries after a fixed duration from creation or replacement when write-based expiration is in play, which fits a short duplicate window well.</p><h4>Collision Thinking for Window Choice</h4><p>Hash collisions belong in the conversation, but they need to stay in proportion. The digest acts as a compact stand-in for the canonical request, so the application trusts the canonical input first and the hash second. With <code>SHA-256</code>, collision risk is tiny for ordinary request dedup windows, yet the digest is still only a compressed token for the request identity built earlier. Trouble usually starts much sooner with poor field selection or weak normalization than with the digest family itself.</p><p>High-stakes endpoints usually need more than a seen bit. Just a marker can stop a repeated write, but a saved status code, prior response id, or business reference gives retries something stable to reconnect to. That does not mean the first duplicate gate has to turn heavy. Payment or order flows usually need enough stored state to tell a retry what already happened, not just that it showed up late.</p><p>The time window should come from business timing rather than from the storage engine. Form-submit guards may need only a few seconds, while mobile retry logic may need a minute or two. Payment processing or outbound callback handling can need a much longer lifetime. Redis string entries with TTL fit exact per-fingerprint windows well. Bucketed Redis sets fit grouped windows with full-bucket expiration. Local <code>Caffeine</code> memory fits JVM-local suppression. Those options live in the same broad topic, but they do not behave the same way.</p><p>Redis also has a native option when you want one shared collection scored by time instead of a plain set or a string per fingerprint. Sorted sets accept members through <code>ZADD</code>, and score-based range reads now belong to <code>ZRANGE</code> with the <code>BYSCORE</code> option. <code>ZRANGEBYSCORE</code> is deprecated, so fresh code should stay with <code>ZRANGE BYSCORE</code> when score-range reads are needed. That route belongs to a different storage model than the ones above, but it still helps frame where current Redis points time-scored membership logic.</p><h3>Conclusion</h3><p>Deduplication and idempotency come down to a few moving parts that have to be in the right order. First the request becomes a stable fingerprint, then that fingerprint is checked against short-lived memory, and from there the write either moves forward or reconnects to a stored result. Get the request identity right and pair it with the right expiration window, and the behavior stays predictable from the first retry to the last one.</p><ol><li><p><em><a href="https://docs.spring.io/spring-boot/reference/io/caching.html">Spring Boot Caching Reference</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-data/redis/reference/">Spring Data Redis Reference</a></em></p></li><li><p><em><a href="https://redis.io/docs/latest/commands/set/">Redis </a></em><code>SET</code><em><a href="https://redis.io/docs/latest/commands/set/"> Command Reference</a></em></p></li><li><p><em><a href="https://redis.io/docs/latest/commands/expire/">Redis </a></em><code>EXPIRE</code><em><a href="https://redis.io/docs/latest/commands/expire/"> Command Reference</a></em></p></li><li><p><em><a href="https://redis.io/docs/latest/commands/sad">Redis </a></em><code>SADD</code><em><a href="https://redis.io/docs/latest/commands/sad"> Command Reference</a></em></p></li><li><p><em><a href="https://redis.io/docs/latest/commands/zadd/">Redis </a></em><code>ZADD</code><em><a href="https://redis.io/docs/latest/commands/zadd/"> Command Reference</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/security/MessageDigest.html">Java </a></em><code>MessageDigest</code><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/security/MessageDigest.html"> Documentation</a></em></p></li><li><p><em><a href="https://github.com/ben-manes/caffeine/wiki/Eviction">Caffeine Eviction Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!NWhV!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!NWhV!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!NWhV!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!NWhV!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!NWhV!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!NWhV!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!NWhV!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!NWhV!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!NWhV!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!NWhV!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F32dda91c-f17a-42c7-8ffb-600bc77fbb9e_276x276.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://icons8.com/icon/90519/spring-boot">Spring Boot</a> icon by <a href="https://icons8.com/">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Dinic's Max Flow in Java]]></title><description><![CDATA[For max flow problems, Dinic&#8217;s algorithm takes a directed graph with capacities on its edges and pushes as much flow as possible from the source to the sink without going past those limits.]]></description><link>https://alexanderobregon.substack.com/p/dinics-max-flow-in-java</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/dinics-max-flow-in-java</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Fri, 27 Mar 2026 17:03:05 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!pdDB!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!pdDB!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!pdDB!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!pdDB!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!pdDB!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!pdDB!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!pdDB!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!pdDB!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!pdDB!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!pdDB!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!pdDB!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F04baf37c-7882-4f84-a5a7-1c80fa5bcc04_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>For max flow problems, Dinic&#8217;s algorithm takes a directed graph with capacities on its edges and pushes as much flow as possible from the source to the sink without going past those limits. It does this in phases. BFS builds a level graph from the current residual graph, then DFS sends flow through that layered graph until no source to sink route remains in it. Repeating those BFS and DFS passes is what makes Dinic faster than one-route-at-a-time methods and gives it the classic <code>O(V^2E)</code> bound in the standard adjacency-list form.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>How Dinic Starts a Phase</h3><p>Every phase begins from the current state of flow, not from the original graph as it first appeared. That idea sets up the whole method. Early pushes may have filled part of an edge, opened room in a reverse direction, or cut off a route that looked open a moment ago. Dinic starts fresh by reading that updated state and building a layered view of what can still be reached from the source. Before that layered view can make sense, the graph needs a way to represent leftover room on forward edges and returned room on backward edges. That is why residual capacity and reverse edges come first.</p><h4>Residual Capacity</h4><p>Leftover room on an edge is what Dinic actually cares about at the start of a phase. If an edge can carry <code>10</code> units and <code>6</code> units are already flowing through it, only <code>4</code> units remain available in the forward direction. That remaining amount is the residual capacity. BFS does not walk through every original edge. It only walks through edges whose residual capacity is greater than <code>0</code>, because those are the only edges that can still carry more flow from the source toward the sink.</p><p>That concept sounds small, but it changes the whole search. Original capacities tell you what the graph allowed at the start. Residual capacities tell you what the graph allows right now after earlier pushes changed it. If Dinic ignored that difference, BFS would keep acting like full edges were still open, and the level graph would be built from bad information.</p><p>Java code usually stores both the capacity and the current flow directly on the edge object. Residual capacity can then be computed with one subtraction:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;893f9d51-5a02-4595-82f0-a8b7bd8115f8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private static final class Edge {
    int to;
    long capacity;
    long flow;

    Edge(int to, long capacity) {
        this.to = to;
        this.capacity = capacity;
        this.flow = 0L;
    }

    long residualCapacity() {
        return capacity - flow;
    }
}</code></pre></div><p>That <code>residualCapacity()</code> method is what the search reads, not the raw <code>capacity</code> field by itself. If <code>flow</code> changes after a push, the next phase sees the new state right away without rebuilding a separate data structure.</p><p>Small number examples make this much easier to to see in practice. Say an edge from node <code>2</code> to node <code>5</code> has capacity <code>9</code>, like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e3fe4a4b-77e1-4045-9268-4000297fda80&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">Edge edge = new Edge(5, 9L);

System.out.println(edge.residualCapacity()); // 9

edge.flow = 4L;
System.out.println(edge.residualCapacity()); // 5

edge.flow = 9L;
System.out.println(edge.residualCapacity()); // 0</code></pre></div><p>At <code>0</code> residual capacity, that edge is fully saturated in the forward direction, so BFS should not walk across it while building the next level graph. That does not mean the connection is gone forever. It only means there is no forward room left at that moment.</p><p>Phase construction depends on this rule. BFS starts from the source and visits neighbors only when the edge to that neighbor still has positive residual room. If the sink cannot be reached through such edges, the algorithm is done. If the sink is reachable, the levels written by BFS form the layered view for that phase. None of that can be trusted unless residual capacity reflects the current flow state exactly.</p><h4>Reverse Edges</h4><p>Flow algorithms need a way to back out part of an earlier choice. Sending flow forward should never trap the graph in a bad decision. Reverse edges solve that problem.</p><p>At the start, a reverse edge usually has capacity <code>0</code>. That sounds odd until flow begins moving. Suppose <code>6</code> units travel from <code>u</code> to <code>v</code>. At that point, the graph should allow up to <code>6</code> units to move back from <code>v</code> to <code>u</code> if a later phase finds a better route. That backward room is represented by the reverse edge. Without it, the algorithm could push flow forward but would have no formal way to reroute that flow later.</p><p>Most Java implementations add forward and reverse edges as a pair, and each one stores the index of its partner inside the other node&#8217;s adjacency list:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;31f7ac78-3777-4f89-b746-746d1a7f8a37&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private static final class Edge {
    int to;
    int reverseIndex;
    long capacity;
    long flow;

    Edge(int to, int reverseIndex, long capacity) {
        this.to = to;
        this.reverseIndex = reverseIndex;
        this.capacity = capacity;
        this.flow = 0L;
    }

    long residualCapacity() {
        return capacity - flow;
    }
}

@SuppressWarnings("unchecked")
private final List&lt;Edge&gt;[] graph = new ArrayList[6];

{
    for (int i = 0; i &lt; graph.length; i++) {
        graph[i] = new ArrayList&lt;&gt;();
    }
}

public void addEdge(int from, int to, long capacity) {
    Edge forward = new Edge(to, graph[to].size(), capacity);
    Edge reverse = new Edge(from, graph[from].size(), 0L);

    graph[from].add(forward);
    graph[to].add(reverse);
}</code></pre></div><p>That pairing matters because flow updates always affect both directions. If a push later sends <code>4</code> units through the forward edge, the reverse edge needs to record that same amount in the opposite direction.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;f5db5891-c333-4655-a73b-a1788b5698e0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">Edge forward = graph[1].get(0);
Edge reverse = graph[forward.to].get(forward.reverseIndex);

long pushed = 4L;
forward.flow += pushed;
reverse.flow -= pushed;</code></pre></div><p>After those two lines run, the forward edge has less room left, and the reverse edge gains room for cancellation. The reverse edge can still keep capacity <code>0</code>, because its residual room comes from the negative flow value. If <code>reverse.flow</code> becomes <code>-4</code>, then <code>reverse.capacity - reverse.flow</code> becomes <code>0 - (-4)</code>, which is <code>4</code>.</p><p>This paired update is the reason reverse edges are built in from the start instead of being invented later as a side structure. Dinic needs a residual graph that changes immediately as flow changes. Forward edges record what more can be sent in the original direction. Reverse edges record what can be taken back and redirected. At the start of a phase, BFS reads both kinds of edges through the same residual capacity rule. That gives the search an accurate picture of the current graph state, including routes created by canceling part of earlier flow. Without reverse edges, the phase would be built from a graph that could only move forward and could never repair an earlier routing choice.</p><h4>Level Graphs</h4><p>After residual capacities and reverse edges are in place, BFS can build the level graph. This is the part that gives Dinic its structure. Starting from the source, BFS assigns level <code>0</code> to the source itself, level <code>1</code> to every reachable neighbor through a positive-residual edge, level <code>2</code> to the next layer, and so on until no more vertices can be reached.</p><p>Those level values are not decoration. They are the rulebook for the current phase. Only edges that go from level <code>d</code> to level <code>d + 1</code> belong to the level graph. Edges that stay on the same level are ignored for that phase. Edges that go backward to an earlier level are ignored too. That restriction keeps the search moving through the shortest residual-distance layers from the source.</p><p>Another way to put it is that the BFS builds a filtered view of the residual graph. The original residual graph may have cycles, sideways connections, and backward links created by reverse edges. The level graph strips that down to a directed layered structure based on current reachability depth. That is what keeps Dinic from wandering through long detours during a phase.</p><p>Let&#8217;s see what a compact BFS in Java usually looks like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;15a923e6-f570-4446-9088-94bcd69fb354&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private int[] level;

private boolean bfs(int source, int sink) {
    Arrays.fill(level, -1);
    ArrayDeque&lt;Integer&gt; queue = new ArrayDeque&lt;&gt;();

    level[source] = 0;
    queue.add(source);

    while (!queue.isEmpty()) {
        int node = queue.poll();

        for (Edge edge : graph[node]) {
            if (edge.residualCapacity() &gt; 0 &amp;&amp; level[edge.to] == -1) {
                level[edge.to] = level[node] + 1;
                queue.add(edge.to);
            }
        }
    }

    return level[sink] != -1;
}</code></pre></div><p>Several things in that loop are worth paying attention to. The <code>level</code> array starts at <code>-1</code> so unreached vertices are easy to spot. The queue gives BFS its layer-by-layer order. Most importantly, the condition checks both positive residual capacity and an unreached destination. If an edge has no room left, BFS acts as if that edge does not exist for this phase.</p><p>After BFS finishes, the <code>level</code> array tells you which edges belong to the level graph. That test is very small:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;bdf685cc-457f-4e4c-8a46-80da6ae86ebc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">boolean inLevelGraph(Edge edge, int from) {
    return edge.residualCapacity() &gt; 0 &amp;&amp; level[edge.to] == level[from] + 1;
}</code></pre></div><p>An edge passes that test only if it has room left and moves exactly one layer farther from the source. If a node at level <code>2</code> points to a node at level <code>4</code>, BFS did discover both vertices, but that edge still does not belong to the level graph because it skips a layer. If a reverse edge points from level <code>3</code> back to level <code>2</code>, that edge is part of the residual graph but not part of the level graph for the current phase.</p><p>This layered restriction is what limits search depth. If the sink is first reached at level <code>5</code>, the phase is built around routes that move through those shortest available layers. The graph may still contain longer residual routes, but Dinic does not let the current phase chase them. Later phases can revisit the graph after residual capacities change and a new BFS writes new levels.</p><p>Sink reachability is the final check that makes the phase meaningful. If <code>level[sink]</code> stays <code>-1</code>, the source can no longer reach the sink through any positive-residual route. That means no new phase can be formed, so the current flow is already maximum. If the sink does get a level, the algorithm has a fresh layered graph ready for the next stage of flow pushes.</p><h3>How Flow Moves Forward</h3><p>After the level graph is built, Dinic turns that layered view into actual flow updates. This part gives the method much of its speed. Instead of rebuilding the search after every successful source to sink route, it keeps pushing through the same layered graph until that phase has no more source to sink route left inside it. DFS sends the flow, a pointer array cuts repeated scans, and the outer control loop keeps the phase moving in the right order.</p><h4>Blocking Flow</h4><p>Within a BFS phase, Dinic does not stop after the first successful source to sink route. It keeps sending flow through the same level graph until every remaining source to sink route is cut off by at least one saturated edge. That state is called a blocking flow.</p><p>Put a little more plainly, the layered graph still exists, but no full source to sink route inside it can carry added flow. At that point, staying in the current phase does not help. DFS would only hit full edges or dead ends, so the algorithm returns to BFS and rebuilds the levels from the updated residual graph. The next phase then starts from a fresh layered view, not from the one that has already been drained as far as it can go.</p><p>This phase-by-phase structure is a big part of why Dinic usually runs faster than older methods that rebuild after every single successful route. One BFS pass is reused for a whole batch of flow updates inside the same level graph. That reuse cuts repeated search cost, which helps explain the standard <code>O(V^2E)</code> time bound for the adjacency-list form.</p><p>Take a current level graph where the source can still reach the sink through three valid routes. DFS may fill an edge on the first route, send flow through a different branch on the next pass, then saturate a shared edge that cuts off the last route still open in that phase. When no complete route remains inside that layered graph, the blocking flow has been reached. BFS can then rebuild levels from the new residual state and start the next phase from there.</p><h4>DFS Pushes Units</h4><p>Flow moves through the level graph by recursive DFS calls, but this DFS does more than mark reachability. Every call carries a number that says how much flow can still pass through the partial route seen so far. At the source, that value starts very large. As recursion follows edges, it drops to the smallest residual capacity seen along the current route. That running minimum becomes the bottleneck for the whole push.</p><p>This code makes the base cases easy to see:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5970beb8-0c78-4796-91ce-c3086870b901&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private long dfs(int node, int sink, long pushed) {
    if (pushed == 0L) {
        return 0L;
    }
    if (node == sink) {
        return pushed;
    }

    return 0L;
}</code></pre></div><p>The first base case stops recursion when nothing useful can be sent. The second returns the amount that reached the sink. That returned value is the bottleneck collected along the route from source to sink in the current level graph.</p><p>As recursion examines an outgoing edge, the amount to send is narrowed by the smaller of the carried value and that edge&#8217;s residual capacity:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;067fcd0c-4963-463a-9935-632710002f73&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">long nextPushed = Math.min(pushed, edge.residualCapacity());
long sent = dfs(edge.to, sink, nextPushed);</code></pre></div><p>If the carried value is <code>8</code> and the next edge has only <code>3</code> units left, the rest of that route can carry at most <code>3</code>. If the edge has more room than the carried value, the bottleneck stays where it already was. That repeated narrowing is what makes the return value meaningful when the sink is finally reached.</p><p>Dead ends are part of the normal control flow here. If recursion reaches a vertex where no valid outgoing edge can carry flow farther toward the sink in the current phase, the call returns <code>0</code>. That <code>0</code> does not end the whole phase. It only tells the caller that this branch cannot send anything right now, so DFS backs up and tries the next outgoing edge from the earlier vertex.</p><p>When a recursive call does reach the sink with a positive amount, that value moves back up through the call stack and updates every edge on that successful route:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;aae98660-8f1f-4d85-93da-37b77fc10204&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">edge.flow += sent;
Edge reverse = graph[edge.to].get(edge.reverseIndex);
reverse.flow -= sent;
return sent;</code></pre></div><p>Forward flow goes up, and the paired reverse edge moves the same amount in the negative direction. That negative value is what gives the residual graph room to send flow back later if a later phase finds a better routing choice.</p><p>For space, the recursion stack can grow to <code>O(V)</code> in the worst case, because a source to sink route in the level graph can include up to <code>V - 1</code> edges. Time for one DFS call depends on how far it travels before it either reaches the sink or runs out of useful edges, which is why the pointer array in the next subsection makes such a difference.</p><h4>Current Edge Pointers</h4><p>Repeated scanning can quietly waste time during DFS. If a vertex has several outgoing edges and the first few have already been proved useless for the current phase, restarting from index <code>0</code> every time recursion returns to that vertex would force the algorithm to recheck the same edges again and again. Dinic avoids that with a current-edge pointer for every vertex. Java code usually stores those positions in an <code>int[]</code> named <code>nextEdge</code> or <code>ptr</code>. That array records the first outgoing edge that still deserves attention in the current BFS phase. Any earlier edge has already been rejected for the wrong level, found full, or fully drained for the current layered graph.</p><p>That loop usually takes this form:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;96802a00-1856-4acf-99df-da07d2e7ee69&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">for (; nextEdge[node] &lt; graph[node].size(); nextEdge[node]++) {
    Edge edge = graph[node].get(nextEdge[node]);

    if (level[edge.to] != level[node] + 1 || edge.residualCapacity() &lt;= 0) {
        continue;
    }

    long sent = dfs(edge.to, sink, Math.min(pushed, edge.residualCapacity()));
    if (sent == 0L) {
        continue;
    }

    edge.flow += sent;
    Edge reverse = graph[edge.to].get(edge.reverseIndex);
    reverse.flow -= sent;
    return sent;
}</code></pre></div><p>That loop does more than scan adjacency lists. It also stores progress inside the current phase. If recursion later returns to the same vertex, the scan resumes from the saved index instead of going back to the start of the adjacency list. That cuts repeated checks and keeps the blocking-flow search far more efficient.</p><p>This is part of why finding one blocking flow in the adjacency-list version is usually analyzed as <code>O(VE)</code>. Without the pointer array, the same outgoing edges could be revisited far too repeatedly during the same phase. With the pointer array, edge scans stay much more controlled.</p><p>Fresh BFS levels mean fresh scanning positions, so the pointer array must be reset at the start of every new phase. Edges that were useless in the old level graph may become valid in the next one after flow values change.</p><h4>Java Implementation</h4><p>Putting these pieces into Java comes down to connecting DFS with the outer loop that alternates between BFS phases and repeated pushes. The fragment below focuses on the control flow after the levels already exist.</p><p>The full flow-update loop reads like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;66bdaa62-ebf0-4896-86ba-df1132891687&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">private long dfs(int node, int sink, long pushed) {
    if (pushed == 0L) {
        return 0L;
    }
    if (node == sink) {
        return pushed;
    }

    for (; nextEdge[node] &lt; graph[node].size(); nextEdge[node]++) {
        Edge edge = graph[node].get(nextEdge[node]);

        if (level[edge.to] != level[node] + 1 || edge.residualCapacity() &lt;= 0) {
            continue;
        }

        long sent = dfs(edge.to, sink, Math.min(pushed, edge.residualCapacity()));
        if (sent == 0L) {
            continue;
        }

        edge.flow += sent;
        Edge reverse = graph[edge.to].get(edge.reverseIndex);
        reverse.flow -= sent;
        return sent;
    }

    return 0L;
}

public long maxFlow(int source, int sink) {
    long totalFlow = 0L;

    while (bfs(source, sink)) {
        Arrays.fill(nextEdge, 0);

        long sent;
        do {
            sent = dfs(source, sink, Long.MAX_VALUE);
            totalFlow += sent;
        } while (sent != 0L);
    }

    return totalFlow;
}</code></pre></div><p>The <code>while (bfs(source, sink))</code> loop means the algorithm keeps forming new phases as long as the sink is still reachable in the residual graph. Inside that phase, <code>Arrays.fill(nextEdge, 0)</code> resets the per-vertex scan positions so DFS can start scanning the new level graph from the front. The <code>do</code> and <code>while</code> loop then keeps calling <code>dfs</code> until it returns <code>0</code>, which means the current phase has reached a blocking flow and cannot send any more source to sink flow through its current layered graph.</p><p>This outer structure is fairly compact, but the control flow is very deliberate. BFS forms the layered graph. Repeated DFS calls drain that graph as far as it can go. A <code>0</code> return marks the end of the phase. BFS then either builds the next level graph or reports that the sink can no longer be reached.</p><p>For runtime, the usual bound for this adjacency-list form is <code>O(V^2E)</code>. Space stays at <code>O(V + E)</code> for the graph storage, level array, and pointer array, with recursion adding up to <code>O(V)</code> stack depth during DFS. Those bounds fit the way Dinic spends more of its time pushing flow through one BFS phase before paying for the next search rebuild.</p><h3>Conclusion</h3><p>Dinic&#8217;s max flow algorithm gets its speed from how it organizes the search. BFS builds a level graph from the current residual graph, DFS pushes flow only through edges that move forward by level, and a phase ends only after that layered graph has been drained into a blocking flow. After that, the residual graph is read again, new levels are assigned, and the cycle repeats until the sink can no longer be reached. That repeated rebuild of levels followed by focused flow pushes is what gives Dinic its <code>O(V^2E)</code> bound in the standard adjacency-list form.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayDeque.html">Java </a></em><code>ArrayDeque</code><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayDeque.html"> Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Arrays.html">Java </a></em><code>Arrays</code><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Arrays.html"> Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html">Java </a></em><code>List</code><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html"> Documentation</a></em></p></li><li><p><em><a href="https://cp-algorithms.com/graph/dinic.html">CP-Algorithms on Dinic&#8217;s Algorithm</a></em></p></li><li><p><em><a href="https://www.cs.princeton.edu/courses/archive/fall07/cos521/handouts/SMJ000507.pdf">Princeton Network Flow Notes</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Spring Boot TTL Expiration with a Time Wheel]]></title><description><![CDATA[Expiration starts as a timing question, because every entry has a deadline and the application needs some way to notice that deadline without spinning up a separate scheduled action for every item.]]></description><link>https://alexanderobregon.substack.com/p/spring-boot-ttl-expiration-with-a-time-wheel</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/spring-boot-ttl-expiration-with-a-time-wheel</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Wed, 25 Mar 2026 21:44:35 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/a43b3a90-419c-41ce-96db-96ced9a543ee_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!82yY!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!82yY!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!82yY!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!82yY!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!82yY!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!82yY!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!82yY!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!82yY!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!82yY!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!82yY!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0b9e1aa5-e67b-40e8-bde7-bb69911df863_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://spring.io/projects/spring-boot">Image Source</a></figcaption></figure></div><p>Expiration starts as a timing question, because every entry has a deadline and the application needs some way to notice that deadline without spinning up a separate scheduled action for every item. Spring Boot gives you caching support and scheduling tools, but the rules for entry expiry still live in the cache code itself, which is why a time wheel fits so well here. Instead of scanning the whole cache, it groups deadlines into buckets and moves through those buckets on a fixed tick, so the expiration cost stays tied to the current slot. Spring Boot cache support also sits on top of provider-backed stores, while Spring scheduling centers on <code>TaskScheduler</code>, so a bucketed expiration model fits naturally with in-memory caches, delayed cleanup, and other time-based cleanup flows.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>Why a Time Wheel Even Fits TTL Expiration</h3><p>Managing expiration gets harder as the number of live entries climbs, not because removal is hard by itself, but because the cache has to keep track of a growing set of deadlines while still staying fast on reads and writes. Every TTL value points to a future moment, and a busy cache can hold thousands of those moments at the same time. When that count rises, the cost no longer comes from deleting expired data alone. Scheduler activity, queue churn, memory use, and repeated deadline bookkeeping all start to take a larger share of the total load.</p><p>Time wheels fit that problem space well because they swap a large collection of separate timers for a fixed ring of time slots. Expiration records are placed into buckets tied to future ticks, and the scheduler advances through those buckets at a regular interval. Instead of checking the whole cache or keeping an individual scheduled removal for every entry, the expiration pass stays focused on the current slot. That keeps the timing side of TTL cleanup tied to the small slice of entries that are due near the current tick.</p><h4>Per Entry Timers Get Expensive Fast</h4><p>Giving every cache entry its own timer can feel appealing early on because the logic reads almost like the TTL rule itself. Put a value in the map, schedule its removal, and let the scheduler handle the rest. That model starts to get heavy as entry counts rise, though, because every cached value now carries timer registration, scheduler queue state, cancellation pressure, and callback overhead along with the data itself.</p><p>Short TTL caches bring that issue into view quickly. Session tokens, short-lived API responses, and temporary lookup data can turn over fast enough that entries are added, replaced, and removed in large waves. If every write schedules its own future removal, the scheduler now has to track not only deadlines that will still be relevant later, but also deadlines tied to entries that may be overwritten long before their timer fires. The cache is then spending more CPU time and memory on timer handling than the removal step itself.</p><p>Early code can look like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e35e3dd8-dd09-40dd-bf24-167e5e971ab1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.Map;
import java.util.concurrent.*;

public class PerEntryTimerCache {
    private final Map&lt;String, String&gt; values = new ConcurrentHashMap&lt;&gt;();
    private final ScheduledExecutorService scheduler =
            Executors.newScheduledThreadPool(4);

    public void put(String key, String value, long ttlSeconds) {
        values.put(key, value);

        scheduler.schedule(() -&gt; values.remove(key), ttlSeconds, TimeUnit.SECONDS);
    }

    public String get(String key) {
        return values.get(key);
    }
}</code></pre></div><p>Code like that gets the TTL idea in a nice visual, but it also leaves a hole behind. If the same key is written again before the prior timer fires, the earlier scheduled removal is still waiting in the scheduler queue. At that point, the cache needs some way to find and cancel the stale removal before it deletes fresh data by mistake.</p><p>More bookkeeping follows from that:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5db43785-0238-45aa-98df-ccb29436437f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.Map;
import java.util.concurrent.*;

public class CancelingTimerCache {
    private final Map&lt;String, String&gt; values = new ConcurrentHashMap&lt;&gt;();
    private final Map&lt;String, ScheduledFuture&lt;?&gt;&gt; removals = new ConcurrentHashMap&lt;&gt;();
    private final ScheduledExecutorService scheduler =
            Executors.newScheduledThreadPool(4);

    public void put(String key, String value, long ttlSeconds) {
        values.put(key, value);

        ScheduledFuture&lt;?&gt; oldFuture = removals.remove(key);
        if (oldFuture != null) {
            oldFuture.cancel(false);
        }

        ScheduledFuture&lt;?&gt; future = scheduler.schedule(() -&gt; {
            values.remove(key);
            removals.remove(key);
        }, ttlSeconds, TimeUnit.SECONDS);

        removals.put(key, future);
    }

    public String get(String key) {
        return values.get(key);
    }
}</code></pre></div><p>Now every write does more than store a value and record a deadline. It has to look up prior scheduling state, cancel the older future if one exists, register a replacement, and keep that future around until it fires or gets replaced again. Under a heavy rewrite rate, that extra bookkeeping can become one of the larger moving parts in the cache.</p><p>Timing precision also needs a little perspective here, cache expiration usually does not depend on exact wall-clock timing down to the last tiny unit. In most TTL caches, removing an entry at the next suitable tick is good enough. That opens the door to a structure that spends less time managing separate timers and more time focusing on groups of entries that are due around the same moment.</p><p>Time wheels fit nicely into that gap. Instead of scheduling a separate future for every key, they place expiration records into shared buckets. The scheduler then advances through those buckets on a fixed interval, which keeps timer bookkeeping from growing in lockstep with the number of live entries.</p><h4>Buckets, Ticks, Round Trips</h4><p>Ring-based timing starts with a fixed number of slots arranged in a circle. Every slot stands for one tick interval, such as one second or 250 milliseconds. New expiration records are placed into the slot that matches how far away their deadline is from the current position on the wheel. As the clock moves forward, the wheel advances slot by slot and checks only the entries stored in the slot it has just reached. Placement comes from very small arithmetic. If the wheel is at slot 10, the delay is 5 ticks, and the wheel size is 60, the expiration record goes into slot 15. If the current position is near the end of the ring, the slot index wraps back to the beginning.</p><p>Small helper methods make the math easier to follow visually, like this one:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;43adf2ac-923c-42c4-a28d-2a37e2b29731&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public class WheelMath {
    public static int slotIndex(long currentTick, long delayTicks, int wheelSize) {
        return (int) ((currentTick + delayTicks) % wheelSize);
    }

    public static void main(String[] args) {
        long currentTick = 10;
        long delayTicks = 5;
        int wheelSize = 60;

        System.out.println(slotIndex(currentTick, delayTicks, wheelSize)); // 15
    }
}</code></pre></div><p>That handles short delays well, but longer TTL values need one more piece. If an entry has to wait longer than a full pass around the wheel, the slot number will repeat. The wheel handles that by storing a round count with the record. The slot tells the wheel where to look, and the round count tells it how many full trips still have to pass before removal is allowed.</p><p>Round counts can be calculated like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;d0d7696a-c96e-4c69-80d0-cc9d51886efb&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public class WheelRounds {
    public static long rounds(long delayTicks, int wheelSize) {
        return (delayTicks - 1) / wheelSize;
    }

    public static void main(String[] args) {
        long delayTicks = 135;
        int wheelSize = 60;

        System.out.println(rounds(delayTicks, wheelSize)); // 2
    }
}</code></pre></div><p>With a 60-slot wheel, a delay of 135 ticks points to a repeated slot. The first return to that slot does not expire the entry. The second return does not either. The later visit after those two full trips is the point where the record becomes due. That lets a compact wheel represent deadlines that reach much farther into the future without growing the ring itself.</p><p>People usually consider wheel movement as <code>O(1)</code> per tick, and the reason is narrow but useful. Advancing from one slot to the next does not require a scan through every timer stored in memory. The pointer moves to the next slot in constant time, and the scheduler touches only the bucket tied to that tick. If the current bucket is empty, the pass is very cheap. If the current bucket is crowded, the cost comes from that bucket alone instead of the full timer population.</p><p>Late wakeups deserve some attention as well. A scheduler does not always run at the exact intended instant. JVM pauses, CPU pressure, or a slow expiration callback can delay the next tick. If the wheel assumes the scheduler was perfectly on time and only checks the slot directly in front of it, entries can remain in the cache after their deadline has already passed.</p><p>Storing the absolute deadline in tick form gives the expiration pass a firmer answer. The wheel still uses slot placement to know where a record belongs, but the deadline value tells it if the entry is actually due when the bucket is processed.</p><p>This small block handles that check:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;bdcf0aca-e20b-40aa-a3f5-7a9edb25cc5a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public class ExpirationCheck {
    static boolean isExpired(long currentTick, long deadlineTick) {
        return currentTick &gt;= deadlineTick;
    }

    public static void main(String[] args) {
        long deadlineTick = 120;
        long currentTick = 123;

        System.out.println(isExpired(currentTick, deadlineTick)); // true
    }
}</code></pre></div><p>Late processing then becomes much less risky. If the wheel wakes after the intended tick, it can compare the current tick against recorded deadlines and remove entries whose expiry moment has already passed. Slot position still matters, but absolute deadline tracking gives the final answer when timing drifts.</p><p>Grouped timing is the reason a time wheel fits TTL expiration so well. Entries with nearby deadlines travel through the same bucket instead of forcing the scheduler to manage separate removal futures for every key. That keeps expiration focused on the current window of time and avoids turning the scheduler into the busiest part of the cache.</p><h3>Building It in Spring Boot</h3><p>Spring Boot already gives you two pieces that line up well with a wheel-based TTL cache. The cache abstraction gives application code a common way to read and write cached data, while Spring scheduling gives you a recurring clock through <code>TaskScheduler</code>. The abstraction does not own the storage engine or the expiry engine by itself, and if you do not bring in a separate cache library, Spring Boot falls back to an in-memory concurrent-map based provider. That leaves room for a wheel-based TTL cache when you want in-process expiration tied to your own data structure rather than to provider-specific expiry behavior. Putting those pieces side by side helps make it all make sense. Spring handles recurring execution, your cache holds live values, and the wheel stores future expiration records. That keeps the scheduler focused on time and keeps the cache focused on data. Annotation-based scheduling on its own does not build bucket queues, store deadline ticks, or deal with overwritten entries, so those details still belong inside the cache class.</p><h4>Scheduling the Tick in Current Spring Boot</h4><p>Current Spring scheduling centers on <code>TaskScheduler</code>, and the current API favors <code>Instant</code> and <code>Duration</code> methods for fixed-rate and fixed-delay scheduling. Older overloads that take <code>Date</code> and <code>long</code> values are deprecated in Spring Framework 6, so a time wheel fits best with <code>Duration</code>-based scheduling from the start. <code>ThreadPoolTaskScheduler</code> remains Spring&#8217;s traditional scheduler for recurring timing like this, and its contract also points out that scheduled callbacks run on the scheduler thread or threads themselves rather than on a separate execution layer.</p><p>That changes how the tick loop should be written. Bucket selection, map lookups, version checks, and queue moves fit well inside the recurring callback. Slow database calls, network calls, and heavier follow-up logic do not. If the wheel tick blocks for too long, later ticks can drift, which leaves expired records around longer than planned. Pool size affects behavior too. A single scheduler thread keeps tick order very predictable for one wheel, while a larger pool only makes sense when separate scheduled jobs should not wait behind the wheel.</p><p>Take this scheduler configuration for example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;defe91f4-c72b-44a4-8ce7-ea356b76dba0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.ttlwheel;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@Configuration
class WheelSchedulerConfig {

    @Bean
    ThreadPoolTaskScheduler ttlWheelScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(1);
        scheduler.setThreadNamePrefix("ttl-wheel-");
        scheduler.setRemoveOnCancelPolicy(true);
        scheduler.setErrorHandler(ex -&gt; System.err.println("Wheel tick failed: " + ex.getMessage()));
        return scheduler;
    }
}</code></pre></div><p><code>setRemoveOnCancelPolicy(true)</code> helps because the scheduler can drop canceled entries from the underlying queue instead of leaving canceled handles around until their trigger time passes. That does not replace stale-record protection inside the wheel, but it does keep the scheduler side lighter when the recurring tick is stopped during shutdown or replaced in tests. <code>ThreadPoolTaskScheduler</code> also exposes the underlying <code>ScheduledExecutorService</code> and <code>ScheduledThreadPoolExecutor</code> when lower-level access becomes useful.</p><p>Starting and stopping the recurring tick usually belongs to bean lifecycle methods:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5ae72209-59ae-40c5-b942-993de31f9155&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.ttlwheel;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.scheduling.TaskScheduler;

import java.time.Duration;
import java.util.concurrent.ScheduledFuture;

public class WheelClock {

    private final TaskScheduler scheduler;
    private ScheduledFuture&lt;?&gt; ticker;

    public WheelClock(TaskScheduler scheduler) {
        this.scheduler = scheduler;
    }

    @PostConstruct
    void start() {
        ticker = scheduler.scheduleAtFixedRate(this::tick, Duration.ofSeconds(1));
    }

    @PreDestroy
    void stop() {
        if (ticker != null) {
            ticker.cancel(false);
        }
    }

    private void tick() {
        // advance the wheel
    }
}</code></pre></div><p>Code like that keeps the wheel clock tied to application startup and shutdown. Startup registers the repeating callback, shutdown cancels it, and the returned <code>ScheduledFuture&lt;?&gt;</code> gives you direct control over that recurring execution. Spring&#8217;s scheduler contract also states that repeated execution ends when the scheduler shuts down or when that future is canceled, which lines up neatly with a wheel owned by a singleton bean.</p><h4>Cache State with Bucket Queues</h4><p>State inside the wheel usually breaks into three layers. Live cache values live in a concurrent map for fast reads. Bucket storage lives in a fixed collection of queues, with one queue per slot. Expiration records carry the data needed later during removal, such as the key, the deadline tick, the remaining wheel rounds, and a token that ties the record to a specific write. Spring Boot&#8217;s fallback in-memory caching already rests on concurrent maps, so building a custom in-process wheel around concurrent collections fits naturally with that direction.</p><p>Keeping live values separate from wheel records helps in two ways. Reads stay focused on the value map instead of searching through timer state, and expiration records can come and go without changing the storage layout for cached values. That separation also leaves room for metadata that belongs to expiry rather than to the value itself. Deadline ticks, wheel rounds, and write tokens belong on expiration records, not on the scheduler or on the bucket list.</p><p>State like that can be laid out with records and concurrent collections:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;a8d46abd-c228-44e3-8b4b-e58d4d022deb&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.ttlwheel;

import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.IntStream;

public class WheelState&lt;K, V&gt; {

    private final int wheelSize;
    private final List&lt;ConcurrentLinkedQueue&lt;ExpirationRecord&lt;K&gt;&gt;&gt; wheel;
    private final ConcurrentHashMap&lt;K, CacheEntry&lt;V&gt;&gt; entries = new ConcurrentHashMap&lt;&gt;();
    private final AtomicLong tokenSequence = new AtomicLong();

    public WheelState(int wheelSize) {
        this.wheelSize = wheelSize;
        this.wheel = IntStream.range(0, wheelSize)
                .mapToObj(i -&gt; new ConcurrentLinkedQueue&lt;ExpirationRecord&lt;K&gt;&gt;())
                .toList();
    }

    record CacheEntry&lt;V&gt;(V value, long deadlineTick, long token) {
    }

    record ExpirationRecord&lt;K&gt;(K key, long deadlineTick, long rounds, long token) {
    }

    int wheelSize() {
        return wheelSize;
    }

    List&lt;ConcurrentLinkedQueue&lt;ExpirationRecord&lt;K&gt;&gt;&gt; wheel() {
        return wheel;
    }

    ConcurrentHashMap&lt;K, CacheEntry&lt;V&gt;&gt; entries() {
        return entries;
    }

    long nextToken() {
        return tokenSequence.incrementAndGet();
    }
}</code></pre></div><p>Fresh values then go through a write flow that stores the live entry, computes its deadline tick, and drops an expiration record into the proper bucket:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;982c50f0-381c-489d-845d-4441e3119672&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.ttlwheel;

import java.time.Duration;

public class WheelWriter&lt;K, V&gt; {

    private final WheelState&lt;K, V&gt; state;
    private final long tickNanos;

    public WheelWriter(WheelState&lt;K, V&gt; state, Duration tickDuration) {
        this.state = state;
        this.tickNanos = tickDuration.toNanos();
    }

    public void put(K key, V value, Duration ttl, long currentTick) {
        long ttlNanos = ttl.toNanos();
        long delayTicks = Math.max(1L, (ttlNanos + tickNanos - 1) / tickNanos);
        long deadlineTick = currentTick + delayTicks;
        long rounds = (delayTicks - 1) / state.wheelSize();
        long token = state.nextToken();

        state.entries().put(key, new WheelState.CacheEntry&lt;&gt;(value, deadlineTick, token));

        int slot = (int) (deadlineTick % state.wheelSize());
        state.wheel().get(slot).offer(
                new WheelState.ExpirationRecord&lt;&gt;(key, deadlineTick, rounds, token)
        );
    }
}</code></pre></div><p>Rounding the TTL upward to at least one tick keeps zero-length scheduling from slipping through by accident. Round counts let a compact wheel carry deadlines that go far beyond a single ring pass, and the token lets the cache connect each expiration record to the specific write that created it. Those parts become more important as keys get rewritten before older expiry records have a chance to fire. Read behavior usually stays separate from bucket handling. The value map answers <code>get</code> calls directly, and a read can also check the stored deadline tick to reject expired data before the background tick reaches that bucket. That keeps stale values from slipping through in the gap between deadline time and the next wheel pass, while the wheel still handles scheduled cleanup in the background.</p><h4>Late Expirations with Stale Timer Records</h4><p>Delayed ticks are unavoidable in any scheduler-based structure. GC pauses, CPU contention, machine-level pressure, or code inside the callback that takes longer than planned can all push the next wheel pass behind schedule. A wheel that only increments by one slot per callback can drift farther behind during those moments, so it is safer to compare the clock-derived target tick against the last processed tick and catch up through every missed tick in order. <code>TaskScheduler</code> supports recurring fixed-rate and fixed-delay execution, but it does not promise perfectly on-time callback execution.</p><p>Stale timer records create a second problem that shows up in the same area. Say a cache entry for <code>alex-token</code> is written with a 30 second TTL, then written again ten seconds later with a fresh value and a new TTL. The first expiration record is still sitting in the wheel. If the wheel later drains that older bucket and blindly removes the key, it can delete the newer value by mistake. That is why the expiration record needs a token or version number that also appears on the live entry. If the tokens differ, the record is stale and should be ignored.</p><p>Catch-up logic and stale-record checks can live in the same flow:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e6455824-4551-4fe3-9c3d-253588b7d8c2&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.ttlwheel;

import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong;

public class WheelAdvancer&lt;K, V&gt; {

    private final WheelState&lt;K, V&gt; state;
    private final AtomicLong processedTick = new AtomicLong(-1);

    public WheelAdvancer(WheelState&lt;K, V&gt; state) {
        this.state = state;
    }

    public void advanceTo(long targetTick) {
        long nextTick = processedTick.get() + 1;

        while (nextTick &lt;= targetTick) {
            drainSlot(nextTick);
            processedTick.incrementAndGet();
            nextTick++;
        }
    }

    private void drainSlot(long tick) {
        ConcurrentLinkedQueue&lt;WheelState.ExpirationRecord&lt;K&gt;&gt; bucket =
                state.wheel().get((int) (tick % state.wheelSize()));

        int bucketSize = bucket.size();
        for (int i = 0; i &lt; bucketSize; i++) {
            WheelState.ExpirationRecord&lt;K&gt; record = bucket.poll();
            if (record == null) {
                break;
            }

            WheelState.CacheEntry&lt;V&gt; entry = state.entries().get(record.key());
            if (entry == null) {
                continue;
            }

            if (entry.token() != record.token()) {
                continue;
            }

            if (record.rounds() &gt; 0) {
                bucket.offer(new WheelState.ExpirationRecord&lt;&gt;(
                        record.key(),
                        record.deadlineTick(),
                        record.rounds() - 1,
                        record.token()
                ));
                continue;
            }

            if (entry.deadlineTick() &lt;= tick) {
                state.entries().remove(record.key(), entry);
            }
        }
    }
}</code></pre></div><p>Absolute deadline ticks carry a lot of weight there. Bucket position tells the wheel where to look, but the stored deadline tick gives the final answer about expiry. That distinction helps when the scheduler wakes late, when the wheel is catching up through missed ticks, or when a record circles back through a bucket after one or more full ring passes. Token comparison protects against stale removals, and <code>remove(key, entry)</code> keeps the delete tied to the exact entry object that was checked a moment earlier.</p><p>Read-side code can also reject expired entries before the wheel drains their bucket. That step does not replace scheduled cleanup, but it does tighten read behavior around the TTL boundary:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;02e35bc8-69a1-4ab0-a298-2ba6dae36be5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public V get(K key, long currentTick) {
    WheelState.CacheEntry&lt;V&gt; entry = state.entries().get(key);
    if (entry == null) {
        return null;
    }

    if (entry.deadlineTick() &lt;= currentTick) {
        state.entries().remove(key, entry);
        return null;
    }

    return entry.value();
}</code></pre></div><p>Read-time expiry and wheel-time expiry serve different purposes. The read path keeps callers from seeing stale values after the deadline has passed. The wheel removes entries that have gone cold and would otherwise remain in memory without being touched again. Paired this way, TTL behavior stays tighter without forcing every entry onto its own private scheduler handle.</p><h4>Fits for Eviction, Delayed Jobs, Session Cleanup</h4><p>In-memory cache eviction is the most natural match for this structure. Values already live inside the application, deadlines are usually short or medium in length, and bucket-based cleanup is more than enough for the timing precision involved. Session cleanup fits the same profile. Login state, temporary verification tokens, and short-lived user state can all expire on wheel ticks without needing their own scheduled callback per entry. Spring Boot&#8217;s cache abstraction can sit above that store, but the timing data structure still lives inside the cache implementation itself.</p><p>Delayed jobs fit too, with an important boundary in mind. For a delay queue that lives inside a single process, a wheel-based model fits well when the delay only needs to survive for as long as that instance is running. If the process stops, that in-memory wheel stops with it. Calendar-based scheduling, restart survival, or cross-node coordination call for a different scheduler. Spring Framework includes Quartz integration, and Spring Boot can auto-configure Quartz with either an in-memory job store or a JDBC-backed store through <code>spring.quartz.job-store-type</code>. That marks a useful dividing line between lightweight in-process expiry timing and durable scheduled job management.</p><p>Session cleanup falls between those two cases. If session state already lives in local memory and losing it on restart is acceptable, the wheel keeps cleanup cheap and predictable. If session lifetime needs durable storage across restarts or across several application instances, the data store itself usually needs to own expiration rather than the application&#8217;s in-memory scheduler. Picking the wheel for the first case and Quartz or provider-managed expiry for the second keeps the timing model aligned with the data lifetime you actually have.</p><h3>Conclusion</h3><p>Time wheel expiration keeps TTL cleanup tied to time slots instead of a separate timer for every entry. Live values stay in the cache, future expiry records stay in the wheel, and the scheduler advances the current tick so only the active bucket needs attention at that moment. Deadline ticks, round counts, and stale-record checks give the cache a practical way to remove expired entries, catch up after delayed ticks, and keep cleanup cost centered on the records due near the current slot.</p><ol><li><p><em><a href="https://docs.spring.io/spring-boot/reference/io/caching.html">Spring Boot Caching</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-framework/reference/integration/scheduling.html">Spring Framework Scheduling</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/TaskScheduler.html">TaskScheduler Javadoc</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/concurrent/ThreadPoolTaskScheduler.html">ThreadPoolTaskScheduler Javadoc</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html">ConcurrentHashMap Javadoc</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ConcurrentLinkedQueue.html">ConcurrentLinkedQueue Javadoc</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Manacher's Algorithm for Finding Palindromes in Java Strings]]></title><description><![CDATA[Palindrome search gets expensive fast when every center has to expand outward from the beginning with no memory of what was already found.]]></description><link>https://alexanderobregon.substack.com/p/manachers-algorithm-for-finding-palindromes</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/manachers-algorithm-for-finding-palindromes</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sat, 21 Mar 2026 17:01:37 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!gBF_!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!gBF_!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!gBF_!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!gBF_!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!gBF_!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!gBF_!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!gBF_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a5aa5608-824c-4ec3-bc42-157478b24522_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!gBF_!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!gBF_!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!gBF_!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!gBF_!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa5aa5608-824c-4ec3-bc42-157478b24522_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Palindrome search gets expensive fast when every center has to expand outward from the beginning with no memory of what was already found. Manacher&#8217;s Algorithm cuts out that repeated checking by carrying forward palindrome length data from earlier positions, so a center inside a larger confirmed match can start with information that is already available before any fresh character comparisons begin. That reuse gives the algorithm a linear-time scan while still finding the longest palindromic substring and the palindrome radius at every center. In Java, arrays and index-based access fit this logic naturally, so the full process is easier to follow than it first appears.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>Why Repeated Expansion Becomes Expensive</h3><p>Most palindrome checks begin with a center and grow outward one step at a time while the characters on both sides still match. That starting idea is easy to follow because every palindrome has some middle point. Odd-length palindromes place that middle on a character, as with <code>racecar</code>, while even-length palindromes place it between two characters, as with <code>abba</code>.</p><p>The slowdown starts because neighboring centers overlap so heavily. A long match found at one center does not automatically help the next center beside it, so the scan ends up rechecking part of the same region again. Small strings can hide that cost, but wider palindromic spans make the repeated comparisons add up quickly.</p><h4>Center Expansion From Scratch</h4><p>Basic center expansion treats every center as if nothing has already been learned. Pick an index, grow outward, stop at the first mismatch, then move to the next index and do nearly the same scan again. That behavior can hide inside compact code because each expansion loop is short. The total cost only becomes obvious when the full pass across the string is viewed as a whole.</p><p>Take <code>abacaba</code> for example, starting from the middle finds a long palindrome. Move one center to the left and the scan still covers a shorter palindrome that overlaps heavily with the earlier result. Move one more center and part of that same region gets checked again. Strings such as <code>aaaaaaa</code> make the repetition easier to notice, because nearly every center expands across a wide span. Each center is valid on its own, but the full search keeps paying for the same character comparisons again and again.</p><p>This Java helper shows the basic expansion rule:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;61ff85b5-1e22-407c-abd6-e27bf5bc69c4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static int expandAroundCenter(String text, int left, int right) {
    while (left &gt;= 0 &amp;&amp; right &lt; text.length()
            &amp;&amp; text.charAt(left) == text.charAt(right)) {
        left--;
        right++;
    }

    return right - left - 1;
}</code></pre></div><p>Left and right move outward while the characters match, and the returned value gives the palindrome length for that single center.</p><p>Finding the longest substring with that helper means calling it across the full string:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;ff8f49bf-10b1-406a-b4e1-4ddd44d8252e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static String longestPalindromeByExpansion(String text) {
    if (text == null || text.isEmpty()) {
        return "";
    }

    int bestStart = 0;
    int bestLength = 1;

    for (int i = 0; i &lt; text.length(); i++) {
        int oddLength = expandAroundCenter(text, i, i);
        int evenLength = expandAroundCenter(text, i, i + 1);
        int currentLength = Math.max(oddLength, evenLength);

        if (currentLength &gt; bestLength) {
            bestLength = currentLength;
            bestStart = i - (currentLength - 1) / 2;
        }
    }

    return text.substring(bestStart, bestStart + bestLength);
}</code></pre></div><p>Odd centers call <code>expandAroundCenter(text, i, i)</code>. Even centers call <code>expandAroundCenter(text, i, i + 1)</code>. That covers both palindrome forms, but it also means each position or gap starts a fresh expansion with no stored radius data from nearby centers.</p><p>Counting comparisons makes the repeated cost easier to track:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;ca7b4655-0705-423f-9f42-4923cc20b187&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static int countComparisons(String text, int left, int right) {
    int comparisons = 0;

    while (left &gt;= 0 &amp;&amp; right &lt; text.length()) {
        comparisons++;
        if (text.charAt(left) != text.charAt(right)) {
            break;
        }
        left--;
        right++;
    }

    return comparisons;
}</code></pre></div><p>Call this method at every center on a string filled with repeated characters and the total comparison count climbs much faster than the input length. That is why the worst-case time cost for center expansion is usually treated as <code>O(n&#178;)</code>. The outer scan visits each center, and the inner loop can travel far for a large share of those centers.</p><p>The expensive part is not the long expansion at a single center. Cost grows because nearby centers keep revisiting overlapping territory from the beginning. That wording gets closer to the real source of the slowdown than a bare statement that the method is quadratic.</p><p>Odd and even centers add more repetition because strings like <code>abbaabba</code> need character-based centers and gap-based centers to cover every case. That full coverage is correct and useful for learning palindromes, but it still doubles the set of starting points. Nothing is carried forward from an earlier expansion, so overlapping checks keep returning.</p><h4>Why Mirrored Radii Help</h4><p>Mirrored radius data changes the question from what this center can prove from zero to what has already been proved near its mirrored partner. That single change removes a large share of the repeated comparison cost.</p><p>Let&#8217;s say a long palindrome has already been found around some midpoint. Any center inside that span has a matching center on the other side of the midpoint. If the left-side center already has a stored radius, the mirrored center on the right begins with part of its own answer already available before any new comparison starts.</p><p>We can see that borrowing step in this Java helper:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;a2158cb0-f928-40a6-8149-bf45c55de052&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static int borrowedRadius(int[] radii, int currentCenter, int currentRight, int index) {
    int mirror = 2 * currentCenter - index;

    if (index &gt;= currentRight) {
        return 0;
    }

    return Math.min(currentRight - index, radii[mirror]);
}</code></pre></div><p>If the current index falls outside the known right edge, borrowing is not possible and the start radius stays at <code>0</code>. If the index is inside that known span, the mirrored center supplies a starting radius. <code>Math.min</code> caps that borrowed value so it never claims more than the current right edge can support.</p><p>That cap is important. Mirrored centers can store a large radius, yet not all of it is safe to copy. Part of that palindrome may extend into territory the current center has not proved on its side. Borrowed radius data gives a trusted starting length, not always the final length.</p><p>Take a region where a known palindrome runs from index <code>4</code> to index <code>14</code>, with center <code>9</code>. Look at index <code>11</code>. Its mirror across center <code>9</code> is index <code>7</code>. If the stored radius at index <code>7</code> is <code>2</code>, then index <code>11</code> can begin with radius <code>2</code> as long as that borrowed length stays inside the known right boundary. Inner characters do not need to be compared again. Fresh comparison starts only after that borrowed distance ends.</p><p>This brief calculation makes the index relationship easier to trace:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8ca99f82-60ec-4ca7-9f6c-25398bb93b69&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">int currentCenter = 9;
int currentRight = 14;
int index = 11;
int mirror = 2 * currentCenter - index;

int mirroredRadius = 2;
int startRadius = Math.min(currentRight - index, mirroredRadius);</code></pre></div><p><code>startRadius</code> equals <code>2</code>, so index <code>11</code> does not restart from radius <code>0</code>. Expansion begins farther out, near the boundary of what still has to be checked.</p><p>That idea forms the bridge between basic center expansion and the later linear-time scan. Earlier center-by-center search repeats inner comparisons at nearby positions. Mirrored radii remove that repetition by carrying forward what earlier centers already proved. New checks still happen, but they begin closer to the unexplored edge, which is why the total comparison count drops so sharply across the full string.</p><h3>How Manacher&#8217;s Algorithm Works in Java</h3><p>After the repeated overlap problem is known, the next step is to see how Manacher&#8217;s Algorithm reorganizes the scan. Instead of treating every center as a separate outward expansion with no carried-forward length data, it restructures the string into a format where every palindrome behaves like the same kind of object. From there, the algorithm keeps track of the farthest confirmed right edge and borrows radius data from mirrored centers whenever that borrowed length is already supported by the palindrome currently in view. Java works great for this logic because arrays, integer indexing, and loops make the moving parts easy to trace from left to right.</p><h4>Turning Every Palindrome Into One Center Style</h4><p>Odd-length palindromes and even-length palindromes create an awkward split if both are handled as two unrelated cases. <code>racecar</code> has a middle character. <code>abba</code> has a middle gap. Without some kind of reformatting, a palindrome scan has to keep remembering which kind of center it is dealing with at each step.</p><p>Manacher&#8217;s Algorithm removes that split by placing separator positions between characters and at both ends. After that change, every palindrome has a center at a single index in the transformed layout. The original string <code>abba</code> can be viewed like this in a teaching-only layout:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;70921e63-8d61-4d99-98a7-83afa7d72a5c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static String visualLayout(String text) {
    StringBuilder layout = new StringBuilder("^");

    for (int i = 0; i &lt; text.length(); i++) {
        layout.append('|').append(text.charAt(i));
    }

    layout.append("|$");
    return layout.toString();
}</code></pre></div><p>Calling <code>visualLayout("abba")</code> produces <code>^|a|b|b|a|$</code>. The even palindrome now has a center at the separator between the two <code>b</code> values, while odd palindromes also still have a center of their own. That means the scan no longer needs separate logic for odd and even cases.</p><p>This string-based view is helpful for learning, but the later full Java version is better off keeping the transformed layout in an <code>int[]</code> rather than a <code>String</code>. Fixed separator characters such as <code>|</code> or <code>#</code> can collide with data already present in the input. An integer layout avoids that issue by reserving sentinel values that do not overlap with real Unicode code points. That transformed layout does more than make the string look uniform. It also gives the radius array a uniform meaning. Each entry records how far a palindrome extends from its center in the transformed space. There is no branch for odd length and no second branch for even length. Every center expands by checking one step left and one step right inside the same layout.</p><p>Mapped back to the original string, that means an odd palindrome such as <code>aba</code> and an even palindrome such as <code>abba</code> both become radius problems with the same rules. When that reformatted view is in place, later parts of the algorithm can focus on boundary tracking and borrowed radius values without having to worry about two separate center types.</p><h4>Tracking the Active Right Boundary</h4><p>Two running values drive the scan after the transformed layout is ready. One holds the center of the palindrome that currently reaches farthest to the right. The other holds that palindrome&#8217;s right edge. Those two values are usually named <code>center</code> and <code>right</code>.</p><p>As the scan moves from left to right, each new index asks a very narrow question. Does this index fall inside the current right boundary, or has it gone past it. That small check determines how much prior radius data can be trusted before any new outward expansion begins.</p><p>If an index falls outside the current right edge, no borrowed radius is available yet. Expansion begins at radius <code>0</code>, because nothing about that new center has been confirmed by the earlier rightmost palindrome.</p><p>If an index falls inside the right edge, then part of the answer is already known. The current rightmost palindrome gives a safe zone where symmetry can be trusted. The scan does not need to restart from zero at that index. Instead, it can begin from a radius value supported by the palindrome already in view. This is also where the linear-time behavior starts to become easier to see. Fresh outward comparison only happens when a center reaches beyond the old right boundary. Every successful push beyond that boundary moves <code>right</code> farther to the right, and that boundary never moves backward. Total outward boundary growth across the full scan therefore stays proportional to the input length, which is a big part of why the total time stays at <code>O(n)</code>.</p><p>The algorithm is still checking centers from left to right, but the scan is no longer blind. It carries a live right boundary and a live center from the farthest-reaching palindrome found so far. That running state changes the full traversal from a sequence of isolated expansions into a connected pass where earlier radius data continues to inform later positions.</p><h4>Reusing Mirrored Data Safely</h4><p>Symmetry is where the saved comparisons come from. When an index falls inside the current right boundary, there is a mirrored index on the opposite side of the current center. Radius data already stored at that mirrored position can give the current index a starting radius before any new character check begins.</p><p>The mirror index comes from a short arithmetic rule:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c4395560-2715-4b7a-8cac-6bec6862a5a1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">int mirror = 2 * center - i;</code></pre></div><p>If <code>i</code> is inside the active right boundary, the algorithm can borrow radius data from <code>radius[mirror]</code>. Still, that borrowed value cannot blindly cross the current right edge. The right boundary marks the farthest territory already confirmed by the current enclosing palindrome, so borrowed length has to stop there if the mirrored radius extends farther.</p><p>We can see an example of this here:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;25cb6bac-6848-4bd4-8474-8cfcdbe727a9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">if (i &lt; right) {
    radius[i] = Math.min(right - i, radius[mirror]);
}</code></pre></div><p><code>right - i</code> is the maximum radius that stays fully inside the current boundary from index <code>i</code>. <code>radius[mirror]</code> is the amount already known at the mirrored position. Taking the smaller of those two values keeps the borrowed radius inside territory that is already verified by symmetry.</p><p>Fresh expansion starts after that borrowed radius has been assigned.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;676391b3-b6e7-4425-b88c-49dc30ae43a8&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">while (layout[i + radius[i] + 1] == layout[i - radius[i] - 1]) {
    radius[i]++;
}</code></pre></div><p>That loop does not repeat the inner comparisons already supported by the current rightmost palindrome. It begins at the first position that still needs new confirmation. This is the reason mirrored data removes so much repeated checking. The algorithm is not copying the full answer from the mirror every time. It is copying the part that is already justified by the active boundary, then asking for new comparisons only beyond that.</p><p>Three situations can come up around that mirrored radius idea. Sometimes the mirrored radius stays fully inside the current right edge. In that case, the borrowed value can be accepted as-is. Sometimes the mirrored radius runs past the left side of the enclosing palindrome, which means only part of it is safe to reuse. In the third case, the current index is outside the right boundary, so no borrowed value is available and expansion begins from zero.</p><p>There is one other thing to keep in mind for the scan. Borrowed radius data is never treated as a blind shortcut. It is only trusted within the span already supported by the current enclosing palindrome. New expansion still happens, but it starts at the edge of known territory instead of at the center. That is what cuts away the repeated inner checks that slowed down basic center expansion.</p><h4>Java Code for the Longest Palindromic Substring</h4><p>The full Java code makes the moving parts easier to connect from start to finish. The version below returns the longest palindromic substring in <code>O(n)</code> time with <code>O(n)</code> extra space. It reads the string as Unicode code points rather than raw UTF-16 <code>char</code> values, so supplementary characters stay intact.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8e9e460f-04fd-4032-98db-921287f7263d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public final class ManacherLongestPalindrome {
    private ManacherLongestPalindrome() {
    }

    public static String longestPalindrome(String text) {
        if (text == null || text.isEmpty()) {
            return "";
        }

        int[] original = text.codePoints().toArray();
        int[] layout = toLayout(original);
        int[] radius = new int[layout.length];

        int center = 0;
        int right = 0;
        int bestCenter = 0;
        int bestRadius = 0;

        for (int i = 1; i &lt; layout.length - 1; i++) {
            int mirror = 2 * center - i;

            if (i &lt; right) {
                radius[i] = Math.min(right - i, radius[mirror]);
            }

            while (layout[i + radius[i] + 1] == layout[i - radius[i] - 1]) {
                radius[i]++;
            }

            if (i + radius[i] &gt; right) {
                center = i;
                right = i + radius[i];
            }

            if (radius[i] &gt; bestRadius) {
                bestRadius = radius[i];
                bestCenter = i;
            }
        }

        int start = (bestCenter - bestRadius) / 2;
        return new String(original, start, bestRadius);
    }

    private static int[] toLayout(int[] original) {
        int[] layout = new int[original.length * 2 + 3];
        layout[0] = -1;

        int j = 1;
        for (int codePoint : original) {
            layout[j++] = -3;
            layout[j++] = codePoint;
        }

        layout[j++] = -3;
        layout[j] = -2;
        return layout;
    }
}</code></pre></div><p><code>text.codePoints().toArray()</code> reads the input as Unicode code points. That avoids splitting supplementary characters into two UTF-16 units, which would throw off palindrome comparisons for some text.</p><p><code>toLayout()</code> builds the transformed array. Negative integers act as sentinels and separators, while real code points remain non-negative. The left sentinel at index <code>0</code> and the right sentinel at the last slot let the expansion loop stop naturally at both ends without extra boundary checks inside the <code>while</code> condition.</p><p><code>radius[i]</code> holds the palindrome radius for transformed index <code>i</code>. <code>center</code> and <code>right</code> track the active palindrome that currently reaches farthest to the right. <code>mirror</code> gives the reflected position across <code>center</code>, which is where borrowed radius data comes from when <code>i</code> falls inside the active boundary.</p><p>The line <code>int start = (bestCenter - bestRadius) / 2;</code> maps the transformed layout back to the original code point array. That division by <code>2</code> comes from the separator layout, where every original code point is separated by one inserted slot.</p><p>Look at the result for <code>abba</code>. In the transformed layout, the longest palindrome grows around a separator center rather than around a character center. Look at <code>racecar</code>, and the longest palindrome grows around a code point center. Both cases still end up with a single radius value in the transformed array, which is exactly why this reformatted layout is so useful for the rest of the scan.</p><p>The full scan still visits each transformed index, but fresh outward comparison only happens at the edge of borrowed radius information. That keeps the total time at <code>O(n)</code>. Extra space stays at <code>O(n)</code> because the code holds the original code point array, the transformed layout, and the radius array.</p><h3>Conclusion</h3><p>Manacher&#8217;s Algorithm gets its speed from the way it reorganizes palindrome search into a single left-to-right pass with carried-forward radius data. After the string is transformed so every palindrome uses the same center format, the scan can track the current rightmost palindrome, borrow safe radius length from mirrored positions, and only compare new characters when the known boundary can be pushed farther. In Java, that logic fits naturally into arrays and index math, which makes the full process easier to trace from the transformed layout all the way back to the longest palindromic substring.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/String.html">Java String Class Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/StringBuilder.html">Java StringBuilder Class Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Character.html">Java Character Class Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/String.html#codePoints%28%29">Java </a></em><code>String.codePoints()</code><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/String.html#codePoints%28%29"> Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/javase/tutorial/i18n/text/unicode.html">Java Language Basics on Unicode</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[LinkedHashMap LRU in Spring Boot]]></title><description><![CDATA[Learn how LinkedHashMap access order drives LRU caching in Spring Boot, including reads, eviction timing, size tracking, and cache fit.]]></description><link>https://alexanderobregon.substack.com/p/linkedhashmap-lru-in-spring-boot</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/linkedhashmap-lru-in-spring-boot</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Tue, 17 Mar 2026 23:26:06 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/1f71e6d7-925d-4a7c-a217-ff4c0ef85073_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!-nxK!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!-nxK!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!-nxK!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!-nxK!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!-nxK!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!-nxK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!-nxK!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!-nxK!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!-nxK!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!-nxK!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9264e9a5-6c9c-431a-baf1-f089b8416ba3_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://spring.io/projects/spring-boot">Image Source</a></figcaption></figure></div><p>In Spring Boot, LRU caching fits a fairly specific set of problems. It works best for a small in-memory set where recently read entries should stay near the front of attention while older ones fall away as fresh data comes in. Java already provides most of what is needed through <code>LinkedHashMap</code> with access order turned on, so the interesting part is not the surface-level API. What actually deserves attention is how the map tracks entry order, what a read changes internally, when eviction happens, what <code>size()</code> is really measuring, and where this kind of in-memory LRU cache belongs inside a Spring Boot application.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>How LinkedHashMap Drives LRU Order</h3><p><code>LinkedHashMap</code> does more than its name first suggests. Most people know it as a map that keeps a stable order, but that order can follow two very different rules. It can follow insertion order, where entries stay in the sequence they were added. It can also follow access order, where entries move after they are touched. LRU behavior comes from that second mode.</p><p>That is why this class appears so frequently in cache discussions. The hash table part still gives quick lookup by key, while the linked entry chain keeps a running memory of recency. One part answers where a value is stored. The other tracks how recently that value was touched compared with everything else already in the map. Those two jobs living in the same structure are what make <code>LinkedHashMap</code> useful for a small LRU cache.</p><h4>The Structure Behind the Map</h4><p>Inside <code>LinkedHashMap</code>, entries do not just live in buckets the way people usually think of a basic <code>HashMap</code>. Every entry is also linked to the one before it and the one after it. That linked chain is what preserves encounter order across the map. Without that chain, a map can answer lookups, but it cannot tell you which entry counts as oldest or newest in any meaningful cache sense.</p><p>Insertion order is the default behavior. Put entries into the map in the order <code>user:1</code>, <code>user:2</code>, <code>user:3</code>, and iteration gives that same order back. Access order changes the story. If the map was created with access order turned on, the entry that gets read or updated moves toward the most recent end of the chain. That turns the linked chain into a running history of recency.</p><p>Seeing the code makes that easier to follow:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;d964beb3-763c-4ef2-a006-57a6337195a9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.LinkedHashMap;
import java.util.Map;

public class AccessOrderMapDemo {
    public static void main(String[] args) {
        Map&lt;String, String&gt; sessions = new LinkedHashMap&lt;&gt;(16, 0.75f, true);

        sessions.put("alex", "token-101");
        sessions.put("kaitlyn", "token-202");
        sessions.put("pippin", "token-303");

        System.out.println(sessions.keySet());
    }
}</code></pre></div><p>That constructor changes the entire behavior of the map. The first value is the initial capacity, the second is the load factor, and the final <code>true</code> tells the map to track access order instead of insertion order. Without that last <code>true</code>, the map still keeps order, but not the order an LRU cache needs.</p><p>The linked entry chain also explains why iteration over a <code>LinkedHashMap</code> feels stable. A plain <code>HashMap</code> does not promise a predictable iteration order. <code>LinkedHashMap</code> does, because iteration walks the linked sequence of entries. In cache terms, the start of iteration gives the least recently encountered entry in access mode, while the end gives the newest.</p><p>Let&#8217;s imagine we have a small map with four entries added in this sequence:</p><pre><code>A B C D</code></pre><p>If access order is off, reading <code>B</code> changes nothing about iteration. The order stays:</p><pre><code>A B C D</code></pre><p>Turn access order on, read <code>B</code>, and it moves to the newest end:</p><pre><code>A C D B</code></pre><p>That movement is the heart of LRU tracking. No separate queue is required. No side list is required. The map&#8217;s linked chain already carries the recency information.</p><p>This small helper makes that behavior easier to follow while the order changes:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;62f6e547-d298-4795-a3a9-3254a3137f7e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.LinkedHashMap;

public class EntryOrderPreview {
    public static void main(String[] args) {
        LinkedHashMap&lt;String, Integer&gt; recentViews =
                new LinkedHashMap&lt;&gt;(16, 0.75f, true);

        recentViews.put("article-11", 11);
        recentViews.put("article-27", 27);
        recentViews.put("article-63", 63);
        recentViews.put("article-88", 88);

        printOrder("Initial order", recentViews);

        recentViews.get("article-27");
        printOrder("After reading article-27", recentViews);

        recentViews.get("article-11");
        printOrder("After reading article-11", recentViews);
    }

    private static void printOrder(String label, LinkedHashMap&lt;String, Integer&gt; map) {
        System.out.println(label + " -&gt; " + map.keySet());
    }
}</code></pre></div><p>Reading <code>article-27</code> does not create a new entry. It does not change the stored value. Placement in the linked chain is what changes. After that read, <code>article-27</code> stops being older than the entries behind it and becomes the newest encountered entry. Reading <code>article-11</code> later repeats that relocation.</p><p>That helps explain why <code>LinkedHashMap</code> works so well for LRU logic. Least recently used is really just oldest by recency order. The map keeps that ordering alive as code interacts with entries.</p><h4>What a Read Changes in Access Order Mode</h4><p>Reading from an access-ordered <code>LinkedHashMap</code> is not passive. That is one of the biggest things to keep in mind while reading or writing LRU cache code based on this type. Calling <code>get</code> for an existing entry does more than hand back the value. It also refreshes that entry&#8217;s recency position.</p><p>Let&#8217;s say the map currently holds these entries from oldest to newest:</p><pre><code>profile-9 profile-12 profile-18 profile-25</code></pre><p>After <code>get("profile-12")</code>, <code>profile-12</code> moves to the newest end, so the order becomes:</p><pre><code>profile-9 profile-18 profile-25 profile-12</code></pre><p>After <code>get("profile-9")</code>, the order changes again:</p><pre><code>profile-18 profile-25 profile-12 profile-9</code></pre><p>That is node movement on reads in practical terms. The node for the accessed entry is detached from its current spot in the linked chain and reattached at the newest end. The lookup still comes from hash-based access. The recency update comes from relinking.</p><p>Seeing it in code makes the movement easier to follow:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;14c2af72-416c-4f6c-b545-ab036c6cdc89&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.LinkedHashMap;

public class ReadMovementDemo {
    public static void main(String[] args) {
        LinkedHashMap&lt;String, String&gt; recentProfiles =
                new LinkedHashMap&lt;&gt;(16, 0.75f, true);

        recentProfiles.put("profile-9", "Alex");
        recentProfiles.put("profile-12", "Kaitlyn");
        recentProfiles.put("profile-18", "Pippin");
        recentProfiles.put("profile-25", "Jordan");

        System.out.println("Start  " + recentProfiles.keySet());

        recentProfiles.get("profile-12");
        System.out.println("Read 12 " + recentProfiles.keySet());

        recentProfiles.get("profile-9");
        System.out.println("Read 9  " + recentProfiles.keySet());
    }
}</code></pre></div><p>That output makes the linked order more straight forward to follow. The map does not need a remove followed by a put to refresh an entry. The read itself is enough to change recency.</p><p>People new to this class sometimes ask why an LRU cache built on <code>LinkedHashMap</code> cannot be treated like a normal read-only map during reads. The reason is that access order changes the internal ordering from the cache&#8217;s point of view. Every cache hit refreshes the entry&#8217;s recency position. Every miss leaves the map unchanged because there is no stored entry to move.</p><p>Looking at insertion order next to access order makes that difference much easier to see visually:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;173a73ed-5e52-492f-a8e8-36d51b8736a2&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.LinkedHashMap;

public class OrderComparisonDemo {
    public static void main(String[] args) {
        LinkedHashMap&lt;String, Integer&gt; insertionOrder =
                new LinkedHashMap&lt;&gt;(16, 0.75f, false);

        LinkedHashMap&lt;String, Integer&gt; accessOrder =
                new LinkedHashMap&lt;&gt;(16, 0.75f, true);

        insertionOrder.put("invoice-101", 101);
        insertionOrder.put("invoice-205", 205);
        insertionOrder.put("invoice-319", 319);

        accessOrder.put("invoice-101", 101);
        accessOrder.put("invoice-205", 205);
        accessOrder.put("invoice-319", 319);

        insertionOrder.get("invoice-205");
        accessOrder.get("invoice-205");

        System.out.println("Insertion order map " + insertionOrder.keySet());
        System.out.println("Access order map    " + accessOrder.keySet());
    }
}</code></pre></div><p>The insertion-order map still prints <code>invoice-101, invoice-205, invoice-319</code> after the read. The access-order map prints <code>invoice-101, invoice-319, invoice-205</code>. Both maps store the same entries. Only the ordering rule changes.</p><p>Not every interaction refreshes recency in the same way people expect. Iterating through <code>keySet()</code>, <code>values()</code>, or <code>entrySet()</code> does not act like a read of every entry. That distinction keeps cache inspection from rewriting LRU history. If simply printing the contents refreshed all entries, then any debug view would scramble the entire recency order and the cache would stop reflecting actual lookup activity.</p><p>That also ties the read behavior back to LRU logic. Least recently used does not mean least recently inserted and those can drift apart very quickly. An entry added earlier can remain fresh if requests keep hitting it. An entry added later can become stale if requests stop touching it. Access order captures that difference directly, which is why it maps so naturally to LRU behavior.</p><h3>Eviction Timing Size Accounting and Spring Boot Fit</h3><p>Recency and removal follow different moments in an LRU cache built with <code>LinkedHashMap</code>. Entry order changes as records are touched, but removal waits for a later point. That timing affects how the cache grows, when older entries leave, and what the map is actually counting while it lives inside a Spring Boot application. Spring Boot does not alter the internal behavior of <code>LinkedHashMap</code>. What changes is the environment around it. Put a cache field inside a singleton service and it lives across requests and across request threads. Build a cache for a narrower scope and the behavior around ownership and sharing changes with it. Before placement makes sense, eviction timing, entry counting, and concurrency need to be pinned down.</p><h4>Eviction Happens After a Write Not After a Read</h4><p>Recency updates happen during access, but eviction does not run during a read. That separation is one of the first things people need to lock in while reading an LRU cache built on <code>LinkedHashMap</code>. Reading an entry can move it to the most recent end of the order, but every current mapping stays in the map after that read. Removal enters the picture only after a new mapping is added and the map checks its eldest entry.</p><p><code>removeEldestEntry</code> is where that policy lives and the method is checked after <code>put</code> or <code>putAll</code> adds a mapping. If the map now holds more entries than the chosen cap, the eldest entry is removed. Put differently, the cache can move past the size limit during insertion, then trim itself as part of that write path.</p><p>Walking through a small example helps. Let&#8217;s say the cache limit is <code>3</code> and the access order from eldest to newest is:</p><pre><code>A B C</code></pre><p>Read <code>A</code>, and the order becomes:</p><pre><code>B C A</code></pre><p>Nothing leaves the cache at that point. The read refreshed recency, but the entry count stayed the same. Add <code>D</code>, and the map now has four entries just long enough to decide who should go. <code>B</code> is eldest at that moment, so <code>B</code> is the entry that gets removed. The resulting order is:</p><pre><code>C A D</code></pre><p>Keeping the policy centered on entry count makes the timing easier to see:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;b85e636b-35ae-4a55-a51b-adba079f1066&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.LinkedHashMap;
import java.util.Map;

public class SmallLruMap&lt;K, V&gt; extends LinkedHashMap&lt;K, V&gt; {
    private final int maxEntries;

    public SmallLruMap(int maxEntries) {
        super(16, 0.75f, true);
        this.maxEntries = maxEntries;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry&lt;K, V&gt; eldest) {
        return size() &gt; maxEntries;
    }
}</code></pre></div><p>That override does a single job. It does not try to remove entries manually from inside the method, and it does not update outside state. It only tells the map when the eldest entry should be removed.</p><p>Running a small driver makes the sequence much easier to follow:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c80368c7-d2b6-4f36-af8a-28ba77d210e1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public class EvictionTimingDemo {
    public static void main(String[] args) {
        SmallLruMap&lt;String, String&gt; cache = new SmallLruMap&lt;&gt;(3);

        cache.put("order-101", "packed");
        cache.put("order-205", "shipped");
        cache.put("order-319", "delivered");
        System.out.println(cache.keySet());

        cache.get("order-101");
        System.out.println(cache.keySet());

        cache.put("order-444", "processing");
        System.out.println(cache.keySet());
    }
}</code></pre></div><p>After the first print, the order is <code>order-101, order-205, order-319</code>. After <code>get("order-101")</code>, the order becomes <code>order-205, order-319, order-101</code>. After <code>put("order-444", "processing")</code>, the order becomes <code>order-319, order-101, order-444</code> because <code>order-205</code> was the eldest entry when the write finished.</p><p>People testing LRU behavior sometimes expect the oldest entry to disappear right after a read refreshes something else. That never happens with this structure. Reads change who counts as oldest. Writes trigger the decision to remove the eldest entry.</p><h4>What Size Accounting Really Means</h4><p><code>size()</code> answers a very narrow question. It tells you how many mappings are in the map at that moment. It does not tell you how large those values are in memory. It does not tell you how expensive they were to create. It does not tell you how much heap pressure the cache is causing. It only counts entries.</p><p>That sounds obvious at first, but the effect is bigger than it looks. Entry count is easy to reason about, which is why the classic <code>LinkedHashMap</code> LRU form is built around <code>size() &gt; maxEntries</code>. Still, two caches with the same entry count can consume very different amounts of memory if one stores tiny strings and the other stores large graphs of objects, nested collections, or large byte arrays.</p><p>Take two maps that both report a size of <code>100</code>. One holds short product codes. The other holds parsed report data with hundreds of rows per entry. Entry count matches, but memory cost is nowhere near the same.</p><p>We can see this a bit better with an example that shows how little <code>size()</code> knows about a value beyond the fact that a mapping exists:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;7a4dc547-fc20-44f1-856d-8b850d866173&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.LinkedHashMap;
import java.util.List;

public class SizeCountingDemo {
    public static void main(String[] args) {
        LinkedHashMap&lt;String, Object&gt; cache = new LinkedHashMap&lt;&gt;(16, 0.75f, true);

        cache.put("status", "ok");
        cache.put("recentIds", List.of(11, 27, 63, 88, 105));
        cache.put("profileBlob", new byte[1024 * 1024]);

        System.out.println(cache.size());
    }
}</code></pre></div><p>The printed value is <code>3</code>. That result stays the same no matter how different the stored values are internally. From the map&#8217;s point of view, three mappings exist, so <code>size()</code> returns three.</p><p>Entry-count LRU works best when cached values live in roughly the same cost range or when the cache cap is small enough that rough counting is good enough. If memory footprint has to track value weight much more closely, then a plain <code>LinkedHashMap</code> entry cap is only a rough stand-in, not a byte-aware cache policy.</p><p>Entry counting also stays separate from recency. Touching an entry moves it in the access order, but <code>size()</code> does not change. Replacing a value for an existing key keeps the same mapping count. Adding a new key increases the count. Removing a key decreases it. Bookkeeping stays easy to follow, but it is still only entry bookkeeping.</p><h4>Thread Safety Inside a Spring Boot Singleton</h4><p><code>LinkedHashMap</code> LRU inside a Spring Boot singleton needs concurrency protection. That point catches people off guard because reads in an access-ordered map are not passive from the structure&#8217;s point of view. Successful <code>get</code> calls move entries to the most recent end, so a read can change internal ordering.</p><p>Spring Boot service beans are singleton by default. Put a mutable cache field inside such a bean, and every request thread that reaches the service can touch the same map. With no coordination, those threads can interfere with the map&#8217;s internal state while reads and writes happen at the same time.</p><p>Look at this service, which stays very close to what people usually write first:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;87bf1206-6343-40a3-ac3b-7d3ac01e660e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import org.springframework.stereotype.Service;

import java.util.LinkedHashMap;
import java.util.Map;

@Service
public class CatalogCacheService {

    private final Map&lt;Long, String&gt; cache = new LinkedHashMap&lt;&gt;(16, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry&lt;Long, String&gt; eldest) {
            return size() &gt; 256;
        }
    };

    public String getCached(Long id) {
        return cache.get(id);
    }

    public void putCached(Long id, String value) {
        cache.put(id, value);
    }
}</code></pre></div><p>Nothing in that class protects the map from concurrent access. Two request threads can call <code>getCached</code> at the same time, and both reads can try to refresh order. Read and write activity can also collide. That is where trouble begins.</p><p>For a small in-memory cache, a synchronized wrapper is usually enough:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;9481392e-f7bd-4493-a49a-ba8bf084e043&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.LinkedHashMap;
import java.util.Map;

public class SynchronizedLruCache&lt;K, V&gt; {
    private final LinkedHashMap&lt;K, V&gt; map;

    public SynchronizedLruCache(int maxEntries) {
        this.map = new LinkedHashMap&lt;&gt;(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry&lt;K, V&gt; eldest) {
                return size() &gt; maxEntries;
            }
        };
    }

    public synchronized V get(K key) {
        return map.get(key);
    }

    public synchronized void put(K key, V value) {
        map.put(key, value);
    }

    public synchronized int size() {
        return map.size();
    }
}</code></pre></div><p>Synchronized methods keep access to the map serialized through that wrapper, which is enough for a small hot set inside a service. That does not turn the cache into a high-throughput general-purpose cache library. It does keep internal ordering and entry updates from being modified by multiple threads at the same instant.</p><p>Bean scope and cache safety are closely tied in Spring Boot. Singleton fields live across requests and need thread coordination. Narrower-scoped caches created inside a single request flow do not carry the same shared-state pressure because only that request touches them.</p><h4>Where This Fits Best in a Modern App</h4><p><code>LinkedHashMap</code> LRU fits best where the cache is small, local, and easy to reason about. Good examples include a tiny hot set of recently requested lookups, a service-local memoization map for repeated reads during a short call chain, parsed fragments that get reused heavily for a brief period, or a request-scoped map that avoids repeating the same work inside a single request.</p><p>That local nature is part of the appeal because no outside cache server is involved. No extra infrastructure is required. The structure lives right in the JVM, and the cost of checking it is low. For a small hot set, that can be enough.</p><p>Inside a request-scoped component, the cache can stay very narrow and vanish at the end of the request. Lifetime and ownership stay easy to follow. This example shows a request-scoped lookup cache that only lives for one request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e2ba2ad0-e0e7-4967-a6a9-48dd7569c930&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.RequestScope;

import java.util.LinkedHashMap;
import java.util.Map;

@Component
@RequestScope
public class RequestPriceCache {

    private final Map&lt;Long, String&gt; cache = new LinkedHashMap&lt;&gt;(8, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry&lt;Long, String&gt; eldest) {
            return size() &gt; 8;
        }
    };

    public String get(Long id) {
        return cache.get(id);
    }

    public void put(Long id, String value) {
        cache.put(id, value);
    }
}</code></pre></div><p>Placement like that works nicely when repeated lookups can happen during one request and there is no need to carry cached values into later requests.</p><p>Small singleton hot sets can also work well if thread safety is handled and the entry cap stays modest. Think about a service that repeatedly loads a narrow set of recently requested summaries, route lookups, or short-lived reference data that is cheap to refresh. Local reuse in that range is where <code>LinkedHashMap</code> LRU usually earns its keep.</p><p>Limits still deserve attention here because this cache stays local to a single application instance. If the same Spring Boot service is running on four instances, each one keeps its own separate cache contents rather than participating in a shared cache. Built-in time-based expiration is not part of this structure, weight-based eviction is not built in either, and richer cache statistics are also absent unless you add that logic yourself.</p><p>That is why this form of LRU is best treated as a narrow in-memory tool, not as the answer to every cache need inside a Spring Boot application. It works well for request-scoped caches and small hot sets where local recency is exactly what you want. Broader caching needs usually call for Spring&#8217;s cache abstraction with a dedicated cache provider, where features such as eviction policy choices, expiry, and metrics go further than a hand-rolled <code>LinkedHashMap</code> field.</p><h3>Conclusion</h3><p><code>LinkedHashMap</code> gives LRU caching its behavior through a very specific set of moving parts. Access order keeps recency up to date as reads happen, <code>removeEldestEntry</code> removes the oldest mapping after a write pushes the map past its limit, and <code>size()</code> tracks entry count rather than memory cost. Put inside a Spring Boot app, that makes this cache style a good fit for small in-memory hot sets where local recency is the main thing you want to preserve.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/LinkedHashMap.html">LinkedHashMap Java Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Map.html">Map Java Documentation</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-boot/docs/current/reference/html/io.html#io.caching">Spring Boot Caching Reference</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-framework/reference/integration/cache.html">Spring Framework Cache Abstraction</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html">ConcurrentHashMap Java Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!WamN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!WamN!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!WamN!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!WamN!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!WamN!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!WamN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!WamN!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!WamN!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!WamN!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!WamN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3943dd5a-5aa6-4e2f-a248-972ae80918f9_276x276.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://icons8.com/icon/90519/spring-boot">Spring Boot</a> icon by <a href="https://icons8.com/">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Bulkhead Concurrency Limits with Semaphores in Spring Boot]]></title><description><![CDATA[One slow downstream dependency can put an entire Spring Boot service under pressure if nothing limits how much work reaches it at the same time.]]></description><link>https://alexanderobregon.substack.com/p/bulkhead-concurrency-limits-with</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/bulkhead-concurrency-limits-with</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Thu, 12 Mar 2026 23:28:11 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/9c25b54e-23c4-4df0-8908-2ca601da644c_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!tOZd!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!tOZd!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!tOZd!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!tOZd!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!tOZd!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!tOZd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!tOZd!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!tOZd!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!tOZd!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!tOZd!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd6f818d0-b391-474c-b122-7eb73be4f93f_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://spring.io/projects/spring-boot">Image Source</a></figcaption></figure></div><p>One slow downstream dependency can put an entire Spring Boot service under pressure if nothing limits how much work reaches it at the same time. Putting a hard cap on in-flight calls for a specific downstream target gives you a fixed boundary, then the service can either wait briefly for capacity or reject extra requests right away after that limit is hit. Semaphores fit that job nicely because they hold a set number of permits and let only that amount of traffic pass at one time. In the current Spring stack, that usually means a servlet <code>Filter</code> or <code>OncePerRequestFilter</code> on the MVC side, and a <code>WebFilter</code> or reactive operator-based limit on the WebFlux side.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>How Semaphore Bulkheads Work</h3><p>Concurrency limits help only when the boundary is firm and easy to reason about. That is why semaphores fit this job well. They give you a fixed count of permits, they let callers enter until that count is exhausted, and they force a decision for everyone who arrives after that point. That decision is where bulkhead behavior starts to matter. Some services reject extra work right away so latency does not stretch upward. Others let callers wait briefly in case a permit frees up soon. Either choice come from the same starting point, which is a fixed ceiling on work already in progress.</p><h4>Permits Set the Hard Ceiling</h4><p>A permit is simply one slot for active downstream work. If a semaphore starts with 20 permits, then no more than 20 callers can pass through that gate at the same time. Caller 21 does not get special treatment just because it arrived a fraction of a second later. It has to wait, or it has to be rejected, based on the policy wrapped around the semaphore.</p><p>That hard ceiling is what gives bulkheads their value. Without a limit, a slow dependency can keep accepting more work until the calling service is tied up with rising response times, backed up threads, or a growing pile of unfinished reactive work. With a semaphore in front of that dependency, the service stops admitting extra load past a fixed point. The downstream call may still be slow, but the amount of damage it can spread is bounded by the permit count.</p><p>Java&#8217;s <code>Semaphore</code> keeps the mechanics small and direct. <code>acquire()</code> waits until a permit is available. <code>tryAcquire()</code> returns right away with <code>true</code> or <code>false</code>. <code>release()</code> gives the permit back. That compact API is enough to build several different admission styles.</p><p>This basic class makes the idea easy to follow:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;1ca20b1b-8c2f-4c3d-b5e5-1d3035fe5047&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.concurrent.Semaphore;

public final class InventoryGatewayLimit {

    private final Semaphore permits = new Semaphore(12, false);

    public boolean tryEnter() {
        return permits.tryAcquire();
    }

    public void leave() {
        permits.release();
    }

    public int availablePermits() {
        return permits.availablePermits();
    }
}</code></pre></div><p>Nothing in that class calls a downstream service yet, but the main rule is already in place. Only 12 callers can be inside the guarded area at one time. <code>availablePermits()</code> can help with visibility during testing or metrics work, but the number that matters most is the starting permit count because that is the upper limit on concurrent access.</p><p>Fairness changes how waiting callers are handed permits when one becomes free. Fair mode tries to honor arrival order among waiting threads. Nonfair mode allows barging, so a later caller can take a permit ahead of an older waiter. The cap itself stays the same. What changes is the handoff order when there is contention.</p><p>We can see in this shorter example that makes the distinction more visual:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;b9b30324-ef58-4194-9adb-28f04b7e256a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.concurrent.Semaphore;

public final class BillingLane {

    private final Semaphore fairPermits = new Semaphore(3, true);
    private final Semaphore nonFairPermits = new Semaphore(3, false);

    public Semaphore fairPermits() {
        return fairPermits;
    }

    public Semaphore nonFairPermits() {
        return nonFairPermits;
    }
}</code></pre></div><p>Both semaphores cap active work at 3. The difference appears only when callers are waiting. Fair mode favors first-in, first-out ordering at the semaphore boundary. Nonfair mode can move work through with a bit less coordination cost. Capacity does not change there. Order does.</p><p>One rule matters a great deal when code is built around permits. Every successful acquire needs a matching release. Lose track of a permit and the gate slowly shrinks. Nothing external changed, yet the service starts acting like its limit got tighter and tighter. That is why the release step belongs in a <code>finally</code> block whenever a permit has been taken.</p><p>This version shows the release in the place where it belongs:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;a2ccc025-f79d-4cba-b231-164cc56db569&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.concurrent.Semaphore;

public final class CatalogLookup {

    private final Semaphore permits = new Semaphore(8, false);

    public String fetchItem(String itemId) throws InterruptedException {
        permits.acquire();
        try {
            return callDownstream(itemId);
        } finally {
            permits.release();
        }
    }

    private String callDownstream(String itemId) throws InterruptedException {
        Thread.sleep(75);
        return "item-" + itemId;
    }
}</code></pre></div><p>The <code>finally</code> block is doing the protective work there. If the downstream call throws, returns early, or gets interrupted after admission, the permit still goes back. That keeps the ceiling stable.</p><p>Permit count selection deserves careful thought too. Semaphores with 500 permits still count as limits, but that is not much of a boundary if the downstream starts struggling after 30 in-flight calls. Setting the value too low can reject traffic earlier than needed. Setting it too high gives the downstream room to pull the caller into the same slowdown you were trying to contain. The cap should reflect how much work that dependency can tolerate while latency and error rates remain acceptable.</p><h4>Fail Fast Versus Queueing</h4><p>Admission policy starts right after the last permit is gone. One option is fail fast. The caller tries <code>tryAcquire()</code>, gets <code>false</code>, and the service refuses the work immediately. The other option is queueing. That caller waits for a permit and enters later if capacity opens up in time.</p><p>Fail fast keeps pressure from stacking up inside the service. Requests that cannot enter do not sit around holding thread state, request data, timeouts, and downstream hopes. It gets a quick answer, and the service holds the line on in-flight work. Latency usually stays more predictable that way because excess traffic is not quietly turned into waiting traffic.</p><p>Take this class for example that shows the basic fail-fast style:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;dc0ddadf-7a58-4421-8be1-7f1f06673f0a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.concurrent.Semaphore;

public final class ShippingBulkhead {

    private final Semaphore permits = new Semaphore(15, false);

    public boolean tryStartCall() {
        return permits.tryAcquire();
    }

    public void finishCall() {
        permits.release();
    }
}</code></pre></div><p>A caller can check <code>tryStartCall()</code>, and if it returns <code>false</code>, reject the work right away. That style is easy to reason about. No hidden line forms behind the limit. Capacity either exists right now or it does not.</p><p>Queueing changes the behavior in a very real way. Instead of refusing entry immediately, the caller waits for a permit. That can help during brief bursts where one permit is about to free up and the wait will be short. Waiting still has a cost. The service is now carrying extra callers that are not doing useful downstream work yet. They are parked at the gate, hoping for admission. If that wait grows, request latency grows with it, and upper-layer timeouts can start firing before the downstream call has even begun.</p><p>Timed waiting is usually the safer form of queueing because it puts a bound on how long a caller can remain at the gate. Untimed waiting is harder to control and can turn pressure into long stalls.</p><p>For example this uses a short wait window:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e8853e12-9e2a-4ee1-9517-1af27d324c44&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public final class PartnerApiGate {

    private final Semaphore permits = new Semaphore(10, true);

    public boolean tryStartCall() throws InterruptedException {
        return permits.tryAcquire(150, TimeUnit.MILLISECONDS);
    }

    public void finishCall() {
        permits.release();
    }
}</code></pre></div><p>That code says a caller can wait up to 150 milliseconds for a permit. If no permit appears during that window, admission fails. Short waits like this can smooth out brief bursts, though the tradeoff is still there. The service is spending part of the request time budget at the gate before the downstream call has even started.</p><p>Fairness matters more when queueing is involved. If callers are waiting, fair ordering gives older waiters a better chance to move forward in arrival order. Nonfair mode can admit newer callers ahead of them. That can raise throughput a bit, but it can also leave older waiters behind for longer. In fail-fast mode, fairness has less room to matter because most callers are not waiting at all. They either get in immediately or they do not.</p><p>Something else worth knowing is that <code>tryAcquire()</code> without a timeout does not respect fairness in the same way waiting acquisition does. That matters because fail-fast bulkheads commonly rely on plain <code>tryAcquire()</code>. So a service can declare a fair semaphore and still see immediate attempts succeed out of arrival order if they hit the gate at the right moment. That is one reason fair mode matters most in queue-heavy admission rather than strict fail-fast admission.</p><p>Service behavior changes a great deal depending on which side you choose. Fail fast favors quick refusal and tighter latency control. Queueing favors giving a burst one more chance to pass. Neither choice changes the permit ceiling itself. The semaphore still caps active work. The difference is what the service does with work that arrives after the gate is full.</p><h3>Spring Boot Implementations</h3><p>Spring Boot supports two different web stacks, and bulkhead placement depends on which one is handling the request and what needs to be capped. Servlet applications process requests through request threads, so a filter is a natural gate before controller work begins. WebFlux handles requests through a reactive, non-blocking model, so request admission can still live in a web filter, while downstream fan-out inside a reactive chain is usually better capped inside the pipeline itself. Current Spring APIs line up with that split through <code>OncePerRequestFilter</code> for servlet applications and <code>WebFilter</code> for reactive applications.</p><h4>Servlet Stack Bulkheads with OncePerRequestFilter</h4><p>Servlet-based Spring MVC applications already have a request interception layer in front of controllers, and <code>OncePerRequestFilter</code> fits that boundary well. It is built to run a filter one time per request dispatch and exposes <code>doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain)</code> for request processing. That makes it a natural place to cap in-flight requests before controller logic starts expensive work or calls a dependency that is already under pressure.</p><p>Spring Boot 3 and 4 use the Jakarta Servlet API, so code in this part of the stack should import <code>jakarta.servlet</code> types rather than the older <code>javax.servlet</code> package names. That matters because it affects copy-paste accuracy for current applications. <code>OncePerRequestFilter</code> also remains part of Spring&#8217;s current <code>org.springframework.web.filter</code> package and still fits normal servlet filter work.</p><p>This fail-fast filter keeps the gate narrow for one request path family and rejects extra work immediately when all permits are in use:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;f0564bee-de9f-4c75-80bb-385ef0efa0d9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.bulkhead;

import java.io.IOException;
import java.util.concurrent.Semaphore;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public final class PaymentBulkheadFilter extends OncePerRequestFilter {

    private final Semaphore permits = new Semaphore(20, false);

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !request.getRequestURI().startsWith("/api/payments");
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        if (!permits.tryAcquire()) {
            response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
            response.setContentType(MediaType.TEXT_PLAIN_VALUE);
            response.getWriter().write("Too many payment requests in flight");
            return;
        }

        try {
            filterChain.doFilter(request, response);
        } finally {
            permits.release();
        }
    }
}</code></pre></div><p>Two parts matter a lot in that filter. <code>shouldNotFilter()</code> narrows the cap to the request group that actually needs protection, which keeps unrelated endpoints from competing for the same permits. The <code>finally</code> block gives the permit back no matter how the request finishes. If that release is skipped after an exception or early return, the bulkhead slowly tightens until the application starts rejecting traffic that should have been allowed. This example assumes the guarded endpoint finishes within the initial servlet dispatch. If the endpoint enters servlet async processing, permit release has to be tied to async completion rather than the return of the initial filter chain.</p><p>Short timed waiting is possible in the servlet stack too. That gives a request a brief chance to enter if a permit is about to free up, but it also means a servlet request thread is sitting at the gate rather than doing useful work. This version shows that style:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;4ff8a8fd-a146-40c9-ab32-93550d31c5dd&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.bulkhead;

import java.io.IOException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public final class InventoryBulkheadFilter extends OncePerRequestFilter {

    private final Semaphore permits = new Semaphore(12, true);

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !request.getRequestURI().startsWith("/api/inventory");
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        boolean acquired = false;
        try {
            acquired = permits.tryAcquire(100, TimeUnit.MILLISECONDS);
            if (!acquired) {
                response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
                return;
            }
            filterChain.doFilter(request, response);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
        } finally {
            if (acquired) {
                permits.release();
            }
        }
    }
}</code></pre></div><p>That version can help with very brief bursts, but it changes the tradeoff. Part of the request time budget is now spent waiting for admission, and the request thread remains occupied during that wait. For servlet applications, that is one of the biggest reasons fail-fast admission stays attractive for bulkheads tied to strained downstream work.</p><h4>Picking Status Codes for Failure Responses</h4><p>Status code choice shapes how clients interpret the refusal. Most bulkhead rejections end up as either <code>429 Too Many Requests</code> or <code>503 Service Unavailable</code>, and both can make sense depending on what you want the response to say.</p><p><code>429</code> is a good fit when the response should read as an admission limit being hit. That can work well when the service is intentionally capping request entry and the client should treat it as a load-related refusal at the edge of the application.</p><p><code>503</code> fits well when the refusal is really about temporary service capacity, such as a downstream dependency that is already saturated or a request class that the service cannot safely admit right now. That framing keeps the message tied to temporary unavailability rather than client behavior.</p><p>Utility code can keep that decision consistent across filters and handlers:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;3df0cf59-dda7-4ab8-9159-b9a981cc6e49&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.bulkhead;

import org.springframework.http.HttpStatus;

public final class BulkheadResponses {

    private BulkheadResponses() {
    }

    public static HttpStatus paymentRefusal() {
        return HttpStatus.SERVICE_UNAVAILABLE;
    }

    public static HttpStatus searchRefusal() {
        return HttpStatus.TOO_MANY_REQUESTS;
    }
}</code></pre></div><p>Consistency matters more than forcing every endpoint into the same status code. Payment requests tied to a fragile downstream provider may read best as temporary unavailability, while a search endpoint with a deliberately narrow concurrency cap may read more naturally as too many requests.</p><p>Headers deserve a brief note too. <code>Retry-After</code> can help if clients are supposed to retry, but it should not be attached casually. Automatic retries from large groups of callers can keep pressure high long after the first overload event. A quick refusal without aggressive retry hints is sometimes the safer choice when downstream capacity is the real problem.</p><p>Error body wording should stay short and factual. Clients usually do not need a long explanation to act on a concurrency refusal. Stable status codes, a compact message, and predictable behavior from one call to the next are what matter most.</p><h4>WebFlux Request Caps with <code>WebFilter</code></h4><p>Reactive request handling changes the runtime model, but request admission still needs a fixed boundary when a certain route should not accept unlimited in-flight work. <code>WebFilter</code> is Spring&#8217;s interception contract for cross-cutting request processing on the reactive side, so it serves as the direct counterpart to servlet filtering in WebFlux. Because WebFlux runs on a non-blocking model, a request-level bulkhead should avoid blocking waits on request-processing threads. That is why fail-fast admission with <code>tryAcquire()</code> maps nicely to a <code>WebFilter</code>. The request either gets a permit immediately or it is rejected right away. No thread is parked waiting for capacity to appear.</p><p>For example this filter caps reactive request entry for one route group:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;32cfdace-59a7-440a-9752-edff3aec54fc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.bulkhead;

import java.util.concurrent.Semaphore;

import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;

import reactor.core.publisher.Mono;

@Component
public final class ReactivePaymentBulkheadFilter implements WebFilter {

    private final Semaphore permits = new Semaphore(20, false);

    @Override
    public Mono&lt;Void&gt; filter(ServerWebExchange exchange, WebFilterChain chain) {
        String path = exchange.getRequest().getPath().value();

        if (!path.startsWith("/api/payments")) {
            return chain.filter(exchange);
        }

        if (!permits.tryAcquire()) {
            exchange.getResponse().setStatusCode(HttpStatus.SERVICE_UNAVAILABLE);
            return exchange.getResponse().setComplete();
        }

        return chain.filter(exchange)
                .doFinally(signalType -&gt; permits.release());
    }
}</code></pre></div><p><code>doFinally</code> handles the lifecycle work there. Reactive request processing can complete normally, fail, or be canceled. Permit release should happen for all of those terminal outcomes so the gate stays accurate.</p><p>Timed waiting with a semaphore is much less attractive in WebFlux than in servlet applications. Blocking permit acquisition does not fit naturally with a non-blocking request pipeline. Thread offloading can make it possible, but that adds moving parts and can blur the bulkhead&#8217;s intent. For request-level gating in WebFlux, immediate admission or immediate refusal usually stays much easier to reason about.</p><h4>Per Downstream Call Caps Inside a Reactive Pipeline</h4><p>Request-level filters cap whole requests. That works particularly well when each request leads to one downstream call or when the request itself is the right boundary to protect. Some endpoints behave differently. One incoming request can fan out into dozens or hundreds of downstream calls through <code>WebClient</code>. In that case, the request may be allowed in, but the downstream fan-out still needs its own cap.</p><p><code>WebClient</code> is Spring&#8217;s reactive HTTP client, and Reactor gives you a direct concurrency cap through <code>flatMap</code> overloads that accept a concurrency value. That gives you a natural way to limit in-flight downstream calls inside a reactive chain without turning the request admission layer into a waiting line.</p><p>This service caps downstream detail lookups at 10 active calls per request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;f3c6c035-5cfb-42b5-a39b-c5fca65e9aab&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.bulkhead;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public final class ItemDetailsService {

    private final WebClient webClient;

    public ItemDetailsService(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("https://details.example.com").build();
    }

    public Mono&lt;List&lt;ItemDetails&gt;&gt; fetchDetails(List&lt;String&gt; ids) {
        return Flux.fromIterable(ids)
                .flatMap(this::loadDetails, 10)
                .collectList();
    }

    private Mono&lt;ItemDetails&gt; loadDetails(String id) {
        return webClient.get()
                .uri("/items/{id}", id)
                .retrieve()
                .bodyToMono(ItemDetails.class);
    }
}</code></pre></div><p>In that code, it caps downstream concurrency without rejecting the entire request at the door. The request enters, but only 10 of its downstream lookups can be active at a time. If the input list contains 200 IDs, the remaining calls wait inside the reactive sequence until one of the active slots finishes. For fan-out work, that usually fits better than a request-level semaphore because the thing being limited is not whole-request entry. It is the number of active downstream calls inside the request.</p><p>Ordering matters too, plain <code>flatMap</code> merges results as inner publishers complete, so result order can differ from source order. If order matters, Reactor also provides <code>flatMapSequential</code> with a maximum concurrency value. That keeps a concurrency cap while preserving source ordering in the merged output.</p><p>Take this example that shows where that becomes useful:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;e8977518-2620-4593-8116-e70a0f0d0984&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.bulkhead;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public final class ReportAssemblyService {

    private final WebClient webClient;

    public ReportAssemblyService(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("https://pricing.example.com").build();
    }

    public Mono&lt;List&lt;PricingView&gt;&gt; loadPricingViews(List&lt;String&gt; skuIds) {
        return Flux.fromIterable(skuIds)
                .flatMapSequential(this::fetchPrice, 6)
                .collectList();
    }

    private Mono&lt;PricingView&gt; fetchPrice(String skuId) {
        return webClient.get()
                .uri("/prices/{skuId}", skuId)
                .retrieve()
                .bodyToMono(PricingView.class);
    }
}</code></pre></div><p><code>flatMapSequential</code> keeps six calls active at a time while still emitting results in the original input order. That small change matters when the caller expects output to stay aligned with the original sequence.</p><h4>Fairness Tradeoffs in Real Services</h4><p>Fairness affects who gets admitted next when callers are waiting, and first-come, first-served can sound attractive right away. The tradeoff is that fairness is about admission order, not extra capacity. The semaphore still allows only the configured number of active holders. What changes is how waiting callers get newly freed permits.</p><p>Queue-heavy admission is where fairness matters most. If a bulkhead allows waiting, fair mode gives older waiters a better shot at moving first. Nonfair mode can let a newer arrival take a permit ahead of them. That can reduce coordination cost a bit, but it can also leave earlier waiters around longer than expected. Fail-fast bulkheads reduce the practical weight of fairness because there is little waiting to order. Immediate <code>tryAcquire()</code> either gets a permit right now or it does not. That means fairness is usually a more visible choice in timed-wait servlet bulkheads than in reactive fail-fast filters or servlet fail-fast filters.</p><p>Seeing code of this can make that contrast easy to understand:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8d1c2c29-d860-43b1-824b-f1c19f24563e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">Semaphore fairGate = new Semaphore(10, true);
Semaphore nonFairGate = new Semaphore(10, false);</code></pre></div><p>The two gates cap active work at 10. Under contention, the first favors waiting order and the second favors faster handoff freedom. There is no universal winner there. Bulkheads attached to a route with short waits and tight latency goals are commonly left nonfair. Bulkheads where queued callers should move in arrival order can justify fair mode, particularly if timed waiting is part of the admission policy.</p><p>There is another subtle point that can surprise people the first time they rely on fair mode. Immediate <code>tryAcquire()</code> does not behave the same way as blocking acquisition with respect to fairness. That means a service built around fast rejection can still see immediate arrivals succeed out of turn if a permit becomes free at just the right moment. For that reason, fairness is most visible when callers are actually waiting, not when the service is built around instant admission decisions.</p><h3>Conclusion</h3><p>Bulkhead concurrency limits with semaphores work because they turn shared capacity into a fixed gate that every request or downstream call has to pass through before work begins. That fixed gate is what keeps pressure from spreading past the point you chose. On the servlet side, that can live at request entry in a filter, while WebFlux can apply the same idea at request entry or deeper in a reactive chain when fan-out work needs its own cap. When permit count, release handling, and admission behavior are set carefully, the service stops taking on more in-flight work than that boundary allows, which keeps overload from quietly piling up in the background.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/Semaphore.html">Java </a></em><code>Semaphore</code><em><a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/Semaphore.html"> Javadoc</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/OncePerRequestFilter.html">Spring </a></em><code>OncePerRequestFilter</code><em><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/OncePerRequestFilter.html"> Javadoc</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/server/WebFilter.html">Spring </a></em><code>WebFilter</code><em><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/server/WebFilter.html"> Javadoc</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-framework/reference/web/webflux-webclient.html">Spring WebClient Reference</a></em></p></li><li><p><em><a href="https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Flux.html">Project Reactor </a></em><code>Flux</code><em><a href="https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Flux.html"> Javadoc</a></em></p></li><li><p><em><a href="https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html">Project Reactor </a></em><code>Mono</code><em><a href="https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html"> Javadoc</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-framework/reference/web/webflux.html">Spring WebFlux Reference</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!KzDX!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!KzDX!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!KzDX!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!KzDX!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!KzDX!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!KzDX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!KzDX!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!KzDX!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!KzDX!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!KzDX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0ddfc044-82b6-4f62-9b10-ebd3abf131d7_276x276.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://icons8.com/icon/90519/spring-boot">Spring Boot</a> icon by <a href="https://icons8.com/">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Building the Sieve of Eratosthenes in Java]]></title><description><![CDATA[Prime number generation shows up in interviews, math tools, and performance benchmarks, and the Sieve of Eratosthenes is a classic way to list primes up to a chosen limit.]]></description><link>https://alexanderobregon.substack.com/p/building-the-sieve-of-eratosthenes</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/building-the-sieve-of-eratosthenes</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Mon, 09 Mar 2026 17:28:35 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!Vn9o!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Vn9o!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Vn9o!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!Vn9o!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!Vn9o!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!Vn9o!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Vn9o!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/52946936-6e98-4a74-a077-89ee449cae16_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Vn9o!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!Vn9o!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!Vn9o!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!Vn9o!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F52946936-6e98-4a74-a077-89ee449cae16_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Prime number generation shows up in interviews, math tools, and performance benchmarks, and the Sieve of Eratosthenes is a classic way to list primes up to a chosen limit. It does that by keeping a flag table for the numbers 0 through <code>n</code>, then stepping through candidates and marking their multiples as not prime until only primes remain flagged.</p><h3>How the Sieve Works Mechanically</h3><p>Prime sieving stays easy to follow when you keep two ideas separate. One part is a flag table that records the current prime status for every number in range. The other part is the marking pass that walks through multiples and flips flags off when a divisor is found.</p><h4>Flag Table Meaning</h4><p>You can think of the flag table as a direct lookup from an integer to its current prime status. Index <code>i</code> corresponds to the number <code>i</code>, so reading or writing the state for a number is just reading or writing one array slot. In Java, a <code>boolean[]</code> fits this well because indexed access is fast. Default values really matter, a fresh <code>boolean[]</code> starts with <code>false</code> everywhere, so the usual start is to set indices <code>2</code> through <code>n</code> to <code>true</code> and leave <code>0</code> and <code>1</code> as <code>false</code> because they are not prime. Those <code>true</code> values represent prime candidates at the beginning, then marking removes the composites.</p><p>Array sizing is the first boundary detail that tends to trip people up. If the goal is to cover the numbers from <code>0</code> through <code>n</code>, index <code>n</code> needs a slot, which means the array length must be <code>n + 1</code>. With length <code>n</code>, the last slot is <code>n - 1</code>, and the number <code>n</code> cannot be represented at all.</p><p>This helper sets up that starting state with the inclusive range in mind:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;9fbc9be6-103f-4925-9d20-5f200c4831e0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">static boolean[] primeFlagsUpTo(int n) {
  boolean[] isPrime = new boolean[n + 1]; // index n exists
  for (int i = 2; i &lt;= n; i++) {
    isPrime[i] = true;
  }
  return isPrime;
}</code></pre></div><p>Reading the table later stays literal. Interpret <code>isPrime[i]</code> as the current flag value for <code>i</code>, not as a mathematical proof in that moment. Before marking runs, most entries from <code>2</code> through <code>n</code> start as <code>true</code>, and after marking finishes, the indices that remain <code>true</code> correspond to primes.</p><p>Printing a small range makes it easier to spot what the marking pass changed without flooding the output, and a helper method keeps the read range inside the array:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;9105accc-1656-48be-9116-d37ba152eaab&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">static void printFlagSlice(boolean[] isPrime, int from, int toInclusive) {
  int end = Math.min(toInclusive, isPrime.length - 1);
  for (int i = from; i &lt;= end; i++) {
    System.out.println(i + &#8220; -&gt; &#8220; + isPrime[i]);
  }
}</code></pre></div><p>The <code>Math.min</code> guard keeps the read range inside the array, which is useful when you are experimenting with different limits and want consistent behavior.</p><h4>Marking Multiples</h4><p>Composite removal happens by walking through multiples of a prime candidate <code>p</code>. Every composite that has <code>p</code> as a factor shows up at positions <code>p * 2</code>, <code>p * 3</code>, <code>p * 4</code>, and so on, so the loop steps through those positions by adding <code>p</code> each time and writes <code>false</code> into the flag table. The start position changes both performance and how easy the logic is to reason about. Starting at <code>p * 2</code> works, but it repeats work that was already done by smaller factors. Starting at <code>p * p</code> avoids that repetition because any multiple smaller than <code>p * p</code> has a factor smaller than <code>p</code>, which means it would have been handled earlier when that smaller factor was processed.</p><p>That same reasoning gives a natural stopping point for the outer loop. After <code>p * p</code> is greater than <code>n</code>, there is no marking work left for <code>p</code> inside the table, because the first multiple you would touch is already outside the range. That is why the outer loop can stop at the condition <code>p * p &lt;= n</code>.</p><p>Something to consider in Java is that multiplying two <code>int</code> values can overflow, and <code>p * p</code> is a multiplication. Casting to <code>long</code> before multiplying keeps the comparison stable for larger limits.</p><p>The method here marks composite numbers by starting at <code>p * p</code>, stepping by <code>p</code>, and stopping at <code>n</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8bf675ff-9c75-45ef-9c3a-b0a64431bc92&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">static void markMultiples(boolean[] isPrime, int n) {
  for (int p = 2; (long) p * p &lt;= n; p++) {
    if (!isPrime[p]) {
      continue;
    }

    long start = (long) p * p;
    for (long m = start; m &lt;= n; m += p) {
      isPrime[(int) m] = false;
    }
  }
}</code></pre></div><p>The inner loop walks the arithmetic sequence <code>p * p</code>, <code>p * p + p</code>, <code>p * p + 2p</code>, and writes <code>false</code> at each visited index. The cast back to <code>int</code> is safe in this context because <code>m</code> never exceeds <code>n</code>, and <code>n</code> is an <code>int</code> bound.</p><p>Helper methods can print the multiple positions so the step size is easy to track while reading the logic. It is separate from the sieve itself, but it follows the same <code>p * p</code> start and <code>+ p</code> stepping as the marking loop:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;a7669639-da00-4321-9e2a-4a8e4a7f16d6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">static String multiplesFromSquare(int p, int n) {
  StringBuilder sb = new StringBuilder();
  for (long m = (long) p * p; m &lt;= n; m += p) {
    if (sb.length() &gt; 0) sb.append(&#8221;, &#8220;);
    sb.append(m);
  }
  return sb.toString();
}</code></pre></div><p>Calling <code>multiplesFromSquare(5, 50)</code> yields <code>25, 30, 35, 40, 45, 50</code>, which lines up with the idea that <code>10</code>, <code>15</code>, and <code>20</code> were already handled earlier through smaller factors like <code>2</code> and <code>3</code>.</p><h3>Java Sieve Implementation With Correct Bounds</h3><p>Array indexing in Java lines up directly with the sieve&#8217;s number range, so the main work is getting bounds right and picking a flag representation that matches the size of <code>n</code>. Array length, mark start at <code>p * p</code>, the <code>p * p &lt;= n</code> stop rule, and flag storage choices are the points that decide how the code behaves.</p><h4>Baseline Implementation With boolean Array</h4><p>Start by reserving one flag per integer from <code>0</code> through <code>n</code>. Index <code>i</code> represents the number <code>i</code>, so the array must be length <code>n + 1</code> so index <code>n</code> exists. New <code>boolean[]</code> values start as <code>false</code>, so prime candidates need to be turned on for <code>2</code> through <code>n</code>, while <code>0</code> and <code>1</code> stay <code>false</code>.</p><p>Lets see how it plays out in code, the method below builds the flags, runs marking, then collects the surviving indices as primes. Casting to <code>long</code> before multiplying avoids overflow in <code>p * p</code> when <code>n</code> is large:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;251d2554-803c-4383-8944-b164b33f1dbd&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.ArrayList;
import java.util.List;

public final class SieveBoolean {

  public static List&lt;Integer&gt; primesUpTo(int n) {
    if (n &lt; 2) return List.of();

    boolean[] isPrime = new boolean[n + 1];

    for (int i = 2; i &lt;= n; i++) {
      isPrime[i] = true;
    }

    for (int p = 2; (long) p * p &lt;= n; p++) {
      if (!isPrime[p]) continue;

      long start = (long) p * p;
      for (long m = start; m &lt;= n; m += p) {
        isPrime[(int) m] = false;
      }
    }

    List&lt;Integer&gt; primes = new ArrayList&lt;&gt;();
    for (int i = 2; i &lt;= n; i++) {
      if (isPrime[i]) primes.add(i);
    }
    return primes;
  }

  private SieveBoolean() {}
}</code></pre></div><p>If you want to keep the memory layout and the output layout mentally aligned, it can be useful to treat the array as the source of truth and derive results from it only at the end. That keeps the marking pass focused on flipping flags, and it keeps the result pass focused on reading flags.</p><h4>Loop Boundaries That Prevent Off-by-One Bugs</h4><p>The outer loop condition and the marking start need to match. Marking starts at <code>p * p</code>, so the outer loop only needs to run while <code>p * p &lt;= n</code>. Once <code>p * p</code> is greater than <code>n</code>, the first multiple that would be marked is already outside the array range, so continuing the outer loop would not change any flags. The stop check can also be written with <code>Math.sqrt(n)</code>, but the <code>p * p</code> form stays tied to the same arithmetic used to start marking. The main detail is the equality case. Exact squares must be handled, so the loop should continue when <code>p * p</code> equals <code>n</code>.</p><p>This helper method shows the stop condition directly as <code>p * p &lt;= n</code>, which is the point where marking still reaches at least one value inside the range:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;539bf9ba-8920-4bb1-8564-a2a87c3f9d08&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">static boolean markingStillHasWork(int p, int n) {
  return (long) p * p &lt;= n;
}</code></pre></div><p>That helper is not required for performance, but it keeps the boundary rule visible. It also makes it harder to accidentally write <code>&lt;</code> where <code>&lt;=</code> is needed.</p><h4>Memory Layout for Flags</h4><p><code>boolean[]</code> keeps one flag for every number, and the code reads naturally because the array index matches the number you are talking about. Memory still grows with <code>n + 1</code>, so very large limits can run into memory pressure. <code>BitSet</code> stores flags as bits rather than bytes, so the same range takes far less space. <code>BitSet</code> keeps the same indexing meaning. Index <code>i</code> still refers to the number <code>i</code>, and <code>0</code> and <code>1</code> stay not prime. The <code>set(from, to)</code> overload takes an exclusive upper bound, so including <code>n</code> means passing <code>n + 1</code>.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;6a785019-8a0e-4229-a21d-780e3e812bb5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.ArrayList;
import java.util.BitSet;
import java.util.List;

public final class SieveBitSet {

  public static List&lt;Integer&gt; primesUpTo(int n) {
    if (n &lt; 2) return List.of();

    BitSet isPrime = new BitSet(n + 1);
    isPrime.set(2, n + 1);

    for (int p = 2; (long) p * p &lt;= n; p++) {
      if (!isPrime.get(p)) continue;

      long start = (long) p * p;
      for (long m = start; m &lt;= n; m += p) {
        isPrime.clear((int) m);
      }
    }

    List&lt;Integer&gt; primes = new ArrayList&lt;&gt;();
    for (int i = isPrime.nextSetBit(2); i &gt;= 0 &amp;&amp; i &lt;= n; i = isPrime.nextSetBit(i + 1)) {
      primes.add(i);
    }
    return primes;
  }

  private SieveBitSet() {}
}</code></pre></div><p>Two boundary rules show up in that version. The first is <code>set(2, n + 1)</code> rather than <code>set(2, n)</code>, because the upper bound is exclusive. The second is the scan loop, which starts at <code>nextSetBit(2)</code> so the first candidate is <code>2</code>, not <code>0</code> or <code>1</code>.</p><h4>Common Off-by-One Issues</h4><p>Small limits make boundary bugs show up fast because there are fewer numbers to hide mistakes. With <code>n = 2</code>, the correct output contains <code>2</code>. If the initialization loop runs <code>i &lt; n</code> instead of <code>i &lt;= n</code>, index <code>2</code> never gets set to <code>true</code>, so the output becomes empty.</p><p>With <code>n = 4</code>, the number <code>4</code> must be marked not prime because it equals <code>2 * 2</code>. If the outer loop runs while <code>p * p &lt; n</code> instead of <code>&lt;= n</code>, then <code>p = 2</code> does not run when <code>n</code> is <code>4</code>, and <code>4</code> stays flagged as prime. With inclusive behavior, the array length must be <code>n + 1</code>. Allocating <code>new boolean[n]</code> silently turns the meaning into less than <code>n</code>, because index <code>n</code> cannot exist. That changes the contract of the method even if the rest of the loops look right.</p><p>This small test checks a few of those edge values and prints what the method returned. The goal is not exhaustive testing. The goal is catching boundary slips that happen at the smallest inputs:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;1971511c-d4d2-4f52-b29f-2fa0ea53087d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.List;

public final class SieveSanity {

  static void check(int n) {
    List&lt;Integer&gt; primes = SieveBoolean.primesUpTo(n);
    System.out.println(&#8221;n=&#8221; + n + &#8220; -&gt; &#8220; + primes);
  }

  public static void main(String[] args) {
    check(0);
    check(1);
    check(2);
    check(3);
    check(4);
    check(10);
  }

  private SieveSanity() {}
}</code></pre></div><p>Mark starts can also create off-by-one behavior. Starting at <code>p * p + p</code> skips the square itself, which leaves perfect squares behind as prime. Starting at <code>p * p</code> marks the square on the first iteration, which matches the idea that <code>p * p</code> is composite whenever <code>p</code> is greater than <code>1</code>.</p><h4>Time and Space Complexity in Big O</h4><p>Runtime for the sieve up to <code>n</code> is <code>O(n log log n)</code>. The marking work is driven by prime candidates, and each prime <code>p</code> touches positions spaced <code>p</code> apart, so the total number of flag writes grows a little faster than linear but far slower than <code>n log n</code>. After marking, scanning the table to collect primes is <code>O(n)</code> because it checks each index from <code>2</code> through <code>n</code> once.</p><p>Space cost is <code>O(n)</code> because the flag table keeps one entry per value from <code>0</code> through <code>n</code>. With <code>boolean[]</code>, space grows with the array length and stores one boolean value per index. With <code>BitSet</code>, the same <code>0</code> through <code>n</code> range is represented as bits, so space stays <code>O(n)</code> but with a smaller constant factor, which matters when <code>n</code> is large enough that memory starts to become the limiter rather than CPU time.</p><h3>Conclusion</h3><p>Sieve code stays reliable when the number range and the storage line up exactly. Allocate <code>n + 1</code> so index <code>n</code> exists, set <code>2</code> through <code>n</code> as prime candidates, then mark composites starting at <code>p * p</code> while the loop condition stays <code>p * p &lt;= n</code>. Keep the multiplication in <code>long</code> so the stop test does not break at larger limits, and pick <code>boolean[]</code> or <code>BitSet</code> based on how large <code>n</code> can get and how much memory you want to spend on flags.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Arrays.html">Java Arrays Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/BitSet.html">Java BitSet Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Math.html">Java Math Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html">Java List Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayList.html">Java ArrayList Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Running Topological Sort With Kahn's Algorithm in Java]]></title><description><![CDATA[Topological sorting shows up any time a directed graph is really a set of ordering rules, like build steps, course prerequisites, or dependency chains between jobs.]]></description><link>https://alexanderobregon.substack.com/p/running-topological-sort-with-kahns</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/running-topological-sort-with-kahns</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Wed, 04 Mar 2026 18:08:58 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!BMdU!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!BMdU!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!BMdU!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!BMdU!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!BMdU!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!BMdU!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!BMdU!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!BMdU!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!BMdU!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!BMdU!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!BMdU!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5ede41ab-7854-48a2-ad82-0564d2f12e6b_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Topological sorting shows up any time a directed graph is really a set of ordering rules, like build steps, course prerequisites, or dependency chains between jobs. You&#8217;re trying to produce one linear order of nodes where every arrow points forward in the list, meaning a prerequisite always appears before what depends on it. Kahn&#8217;s algorithm handles that by keeping a running count of incoming edges for each node, pushing all currently unblocked nodes into a queue, then repeatedly taking one out and removing its outgoing edges from the graph. If the queue runs dry and some nodes still have incoming edges, those leftovers point to a cycle, which means no topological order exists for the whole graph.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>Mechanics of Kahn&#8217;s Algorithm</h3><p>Kahn&#8217;s algorithm runs on a directed graph where every edge means a one way dependency. That dependency meaning is the whole story for the sort. If there is an edge from node U to node V, U has to appear earlier than V in the final ordering, because V depends on U.</p><h4>Graph Setup</h4><p>Directed graphs are easiest to work with when you pick a consistent node identity. Most implementations label nodes as integers from <code>0</code> through <code>n - 1</code>, then keep a separate count <code>n</code> so arrays stay practical later for indegree tracking. The other choice is how to store edges. Kahn&#8217;s method repeatedly takes a node and walks every outgoing edge from it, so you want fast access to neighbors. That is why an adjacency list is common. Each node stores a list of nodes it points to, meaning the nodes that depend on it.</p><p>A good way to think about it is to treat node U as work that has already been completed. Every outgoing edge from U to V means V was waiting on U, so removing U also removes those outgoing edges and moves V closer to being ready. That only works smoothly when outgoing neighbors are easy to access.</p><p>This builds an adjacency list from a set of directed edges:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;bdcd729f-99b2-44b1-a684-73373b10ce45&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.ArrayList;
import java.util.List;

public final class GraphBuild {

    public static List&lt;List&lt;Integer&gt;&gt; buildAdjacency(int n, int[][] edges) {
        List&lt;List&lt;Integer&gt;&gt; graph = new ArrayList&lt;&gt;(n);
        for (int i = 0; i &lt; n; i++) {
            graph.add(new ArrayList&lt;&gt;());
        }

        for (int[] e : edges) {
            int from = e[0];
            int to = e[1];
            graph.get(from).add(to);
        }

        return graph;
    }

    public static void main(String[] args) {
        int n = 5;
        int[][] edges = {
                {0, 2},
                {1, 2},
                {2, 3},
                {2, 4}
        };

        List&lt;List&lt;Integer&gt;&gt; graph = buildAdjacency(n, edges);
        System.out.println(graph);
    }
}</code></pre></div><p>That <code>graph</code> structure stores outgoing edges only. If you need incoming edges later, Kahn&#8217;s method does not store them directly, because incoming edge counts are tracked in a separate table. Keeping outgoing neighbors in the adjacency list keeps dependency removal cheap, because removal is represented by walking neighbors and decrementing counts rather than physically deleting entries from lists. With a cycle, every node in the cycle has an incoming edge from inside the cycle, so none of them can ever be the first item in an ordering.</p><p>Some inputs come in as prerequisite pairs where the meaning is prereq to course. Converting that meaning into a directed edge is the main step. If prereq P must happen before item C, the edge should go P to C.</p><p>This helper keeps that direction explicit at the call site, which reduces the chance of reversing the edge by mistake:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;d741e748-6c9a-42cb-ab17-837503d3a0f3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.List;

public final class EdgeAdd {

    public static void addDependency(List&lt;List&lt;Integer&gt;&gt; graph, int prereq, int dependent) {
        graph.get(prereq).add(dependent);
    }
}</code></pre></div><p>Sticking to one consistent direction matters because all later reasoning depends on it. Once the graph is built, every other step in Kahn&#8217;s method reads like bookkeeping on top of that direction.</p><h4>Indegree Table</h4><p>Indegree means the number of incoming edges for a node. In dependency terms, it is the number of prerequisites that still point into that node.</p><p>The indegree table is built by scanning every edge U to V and incrementing indegree of V. That count is the only part of the method that represents incoming edges directly. After the table is built, the rest of the work happens by decrementing those counts as dependencies are removed.</p><p>Reading the count as a blocked level works well. Indegree <code>0</code> means nothing has to come before the node, so it can appear right at the front of the ordering. Indegree greater than <code>0</code> means it&#8217;s still waiting on that many prerequisites.</p><p>This code computes indegree from the same edge list:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;38a26c82-02bf-4e01-beb6-ddf4fc88ad46&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.Arrays;

public final class IndegreeBuild {

    public static int[] buildIndegree(int n, int[][] edges) {
        int[] indegree = new int[n];

        for (int[] e : edges) {
            int to = e[1];
            indegree[to]++;
        }

        return indegree;
    }

    public static void main(String[] args) {
        int n = 5;
        int[][] edges = {
                {0, 2},
                {1, 2},
                {2, 3},
                {2, 4}
        };

        int[] indegree = buildIndegree(n, edges);
        System.out.println(Arrays.toString(indegree));
    }
}</code></pre></div><p>That output tells you which nodes have no prerequisites right away. In this sample, node <code>2</code> has indegree <code>2</code> because two nodes point to it. Nodes <code>3</code> and <code>4</code> each have indegree <code>1</code> because node <code>2</code> points to them. Nodes <code>0</code> and <code>1</code> have indegree <code>0</code> because nothing points into them. Those numbers are also what make cycle detection possible later. Every time an edge is removed, indegree drops. If a directed cycle exists, every node in that cycle always has at least one incoming edge from within the cycle, so indegree for those nodes never falls to <code>0</code>.</p><p>Time cost for building indegree is <code>O(E)</code>, where E is the number of edges, because each edge is scanned one time. Space cost for the indegree table is <code>O(V)</code>, where V is the number of nodes, because it stores one integer per node.</p><h3>Queue Flow in Kahn&#8217;s Algorithm</h3><p>After the adjacency list and indegree array exist, the rest of Kahn&#8217;s algorithm is a repeating flow that moves ready nodes through a queue and updates indegrees as dependencies get removed. The queue is the working set of nodes that currently have no remaining incoming edges, so every pop from that queue is safe to place next in the final order.</p><h4>Starting Queue</h4><p>First step is filling the queue with every node whose indegree is <code>0</code>. That set represents nodes with no prerequisites, so they can appear at the front of the ordering without breaking any edge rule.</p><p>Multiple nodes can have indegree <code>0</code> at the same time. Picking any of them is valid, which is why a topological order is often not unique. A FIFO queue is common because it is fast and keeps the algorithm easy to follow. Some problems want a predictable smallest-id result, and that swaps the FIFO queue for a min-heap like <code>PriorityQueue&lt;Integer&gt;</code> so the smallest available node is chosen each time. The underlying rules do not change, only tie handling changes.</p><p>Take this helper that builds the initial queue from an indegree array. It uses <code>ArrayDeque</code> and adds nodes in numeric order, which keeps the initial insert stable:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;6b7e0a96-00d1-4eeb-8ac5-4389746fd550&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.ArrayDeque;
import java.util.Deque;

public final class StartQueueBuild {

    public static Deque&lt;Integer&gt; buildStartQueue(int[] indegree) {
        Deque&lt;Integer&gt; queue = new ArrayDeque&lt;&gt;();
        for (int node = 0; node &lt; indegree.length; node++) {
            if (indegree[node] == 0) {
                queue.addLast(node);
            }
        }
        return queue;
    }
}</code></pre></div><p>That scan is <code>O(V)</code> time with <code>O(V)</code> storage for the queue in the worst case, where <code>V</code> is the number of nodes. Graphs with no edges put every node into the queue at the start, which is a helpful sanity check when stepping through the algorithm by hand.</p><h4>Processing Loop</h4><p>Work happens in a loop that runs until the queue is empty. Each iteration takes one node from the queue, appends it to the output order, then visits every outgoing neighbor of that node to update indegrees. Removing from the queue means the node is being committed into the ordering. It is safe because indegree <code>0</code> means no remaining prerequisites point to it. Appending it to the output is the physical record of that commitment.</p><p>The loop skeleton here keeps attention on queue movement and building the output order, leaving the neighbor updates for the next step:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;101af526-5fea-4d6e-8a0f-ac4db9482ff6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

public final class ProcessLoopCore {

    public static List&lt;Integer&gt; drainQueue(Deque&lt;Integer&gt; queue) {
        List&lt;Integer&gt; order = new ArrayList&lt;&gt;();
        while (!queue.isEmpty()) {
            int node = queue.removeFirst();
            order.add(node);
        }
        return order;
    }
}</code></pre></div><p>In the full algorithm, the queue is not drained in one pass like this because neighbor updates keep adding more nodes. Still, the control flow is the same. Pop a node, record it, then do work based on that node. <code>ArrayDeque</code> is a solid choice in Java for this queue behavior. <code>addLast</code> and <code>removeFirst</code> are constant time, and it avoids the overhead that comes with linked structures.</p><h4>Indegree Drop Step</h4><p>Indegree dropping is the bookkeeping step that turns edge removal into numbers. After a node is placed into the output, treat all edges that leave it as removed. For each neighbor <code>next</code> in <code>graph.get(node)</code>, decrement <code>indegree[next]</code> by <code>1</code>. If that decrement makes <code>indegree[next]</code> equal <code>0</code>, that neighbor has no remaining prerequisites, so it gets pushed into the queue.</p><p>This is the point where newly unblocked nodes enter the flow, so it belongs directly inside the loop, right after the node is taken from the queue.</p><p>Let&#8217;s see a Java method next that runs the loop and applies the indegree updates. It assumes <code>graph</code> is an adjacency list where <code>graph.get(u)</code> returns outgoing neighbors from <code>u</code>.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;9504b658-0480-4392-81a5-7ce945862b53&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

public final class KahnStep {

    public static List&lt;Integer&gt; runSteps(List&lt;List&lt;Integer&gt;&gt; graph, int[] indegree) {
        Deque&lt;Integer&gt; queue = StartQueueBuild.buildStartQueue(indegree);

        List&lt;Integer&gt; order = new ArrayList&lt;&gt;(indegree.length);

        while (!queue.isEmpty()) {
            int node = queue.removeFirst();
            order.add(node);

            for (int next : graph.get(node)) {
                indegree[next]--;
                if (indegree[next] == 0) {
                    queue.addLast(next);
                }
            }
        }

        return order;
    }
}</code></pre></div><p>Each edge is processed exactly one time in that inner <code>for</code> loop, because each edge appears once in the adjacency list and gets visited when its <code>from</code> node is removed. That is the reason the full run time lands at <code>O(V + E)</code>. Storage stays <code>O(V + E)</code> as well, driven by the adjacency list plus the arrays and output list.</p><p>Duplicate edges follow the same math. If the edge U to V appears twice, indegree for V starts higher by <code>2</code>, and the two entries in U&#8217;s adjacency list will cause two decrements later. The queue only receives V when indegree hits <code>0</code>, so it still becomes ready at the right time relative to the stored edges.</p><h4>Nodes Left at the End</h4><p>The algorithm finishes when the queue becomes empty. At that point, check how many nodes made it into the output list. If the output list size equals V, then every node was placed in order. If the output list is shorter than V, some nodes never reached indegree <code>0</code>. That only happens when the remaining nodes still have incoming edges that never get removed, which means there is a directed cycle somewhere in that remaining set. In a cycle, each node depends on another node in the cycle, so no member of the cycle can ever be placed into the queue.</p><p>This check keeps cycle detection tied to the algorithm&#8217;s own state rather than adding a separate pass:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;140a2563-b6bf-4ebe-9daf-8b4bbcc8bf2e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.List;

public final class CycleCheck {

    public static boolean hasCycle(int nodeCount, List&lt;Integer&gt; order) {
        return order.size() != nodeCount;
    }
}</code></pre></div><p>Different call sites handle the cycle case differently. Some return an empty list. Some throw an exception. Some return the partial order plus a boolean. The shared idea is that a short output is not a partial win for topological sorting, it is the signal that a full ordering does not exist for that graph.</p><h3>Conclusion</h3><p>Kahn&#8217;s algorithm works by turning dependency arrows into counts, then letting those counts drive the order. Build the adjacency list so outgoing edges are easy to walk, build indegree so incoming pressure is tracked per node, then push every indegree <code>0</code> node into the queue as the starting set. Each time a node leaves the queue, it gets recorded in the output and its outgoing edges get removed by decrementing indegree on neighbors, with any neighbor that hits indegree <code>0</code> joining the queue next. When the queue empties, a full output of size V means a valid topological order was found, while any leftover nodes point directly to a cycle that prevents a complete ordering.</p><ol><li><p><em><a href="https://en.wikipedia.org/wiki/Topological_sorting">Topological Sort</a></em></p></li><li><p><em><a href="https://en.wikipedia.org/wiki/Directed_acyclic_graph">Directed Acyclic Graph</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayDeque.html">Java Platform SE ArrayDeque</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Deque.html">Java Platform SE Deque</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/PriorityQueue.html">Java Platform SE PriorityQueue</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Collections.html">Java Platform SE Collections</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html">Java Platform SE List</a></em></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p></li></ol>]]></content:encoded></item><item><title><![CDATA[Median of a Data Stream with Two Heaps in Java]]></title><description><![CDATA[Streaming numbers one at a time is common in dashboards, logging, and anything that keeps a live metric running, and you usually want the median value right after every insert without sorting the full set again.]]></description><link>https://alexanderobregon.substack.com/p/median-of-a-data-stream-with-two</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/median-of-a-data-stream-with-two</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Tue, 03 Mar 2026 18:43:18 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!aUI8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!aUI8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!aUI8!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!aUI8!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!aUI8!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!aUI8!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!aUI8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!aUI8!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!aUI8!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!aUI8!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!aUI8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F55cd539b-a5e7-44b0-b000-2e0be0506a7a_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Streaming numbers one at a time is common in dashboards, logging, and anything that keeps a live metric running, and you usually want the median value right after every insert without sorting the full set again. A good way to do that is to keep the values split into a lower half and an upper half, then keep the split balanced so the boundary stays tight. With that split in place, the answer is always sitting right at the top edges of those halves, so reading it out takes constant time after each insert. This median tracking technique is commonly known as the two heaps method.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>What Two Heaps Store</h3><p>The two heaps method gives you a very specific storage layout. One heap always exposes the largest value from the lower side, and the other heap always exposes the smallest value from the upper side, so the boundary values stay at the surface through <code>peek()</code>. The storage goal is to keep those boundary values meaningful as inserts happen, with the rest of each half tucked away inside heap order rather than fully sorted order.</p><h4>Heap Configuration</h4><p>Lower half means the smaller side of the stream so far. Fast access to the largest value in that lower half matters because it sits right at the boundary between the two halves. When the total count is odd, the median is one of the boundary values. When the total count is even, the median lives between them, so the boundary values still matter. That is why the lower half is stored in a max heap. In a max heap, the head is the largest item, so <code>peek()</code> gives the boundary value in constant time. Java&#8217;s <code>PriorityQueue</code> is a min heap by default, so the common move is to build it with <code>Collections.reverseOrder()</code> to flip the ordering and get max heap behavior.</p><p>As new values come in, this heap keeps representing the lower side of the stream, and its head stays the largest value from that side. The heaps themselves are just two <code>PriorityQueue&lt;Integer&gt;</code> instances configured differently, and you read the boundary with <code>peek()</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;8d0f9e71-cc3e-45b4-ab78-8f3777324d59&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">PriorityQueue&lt;Integer&gt; lower = new PriorityQueue&lt;&gt;(Collections.reverseOrder()); // max heap
PriorityQueue&lt;Integer&gt; upper = new PriorityQueue&lt;&gt;(); // min heap

if (!lower.isEmpty()) {
    int lowerBoundary = lower.peek();
}

if (!upper.isEmpty()) {
    int upperBoundary = upper.peek();
}</code></pre></div><p>Early in the stream, one heap can be empty and the other can have values. That is normal at the start. It just means only one boundary exists until at least one value lands in the other heap.</p><p>The upper heap also handles duplicates naturally, including duplicates that match the lower boundary. If values equal to the boundary show up on both sides, <code>upper.peek()</code> can equal <code>lower.peek()</code> and the boundary still makes sense. The two halves can touch at the boundary value, and median reads later still work because the boundary values are meant to meet. The lower head is the maximum of the lower half and the upper head is the minimum of the upper half. Those two values are what the median logic reads later.</p><h3>How Inserts Balance and Median Reads Out</h3><p>Insert work comes down to two actions that repeat for every number. First, place the new value into one of the heaps based on where it belongs relative to the boundary. Second, rebalance sizes if one heap pulled ahead by more than one item. With those two actions done, the median is always available from <code>peek()</code> calls.</p><h4>Picking a Side for a New Value</h4><p>Placing the next value starts by looking at the boundary on the lower side. That boundary value is <code>lower.peek()</code>, which is the largest value in the lower half. If the lower heap is empty, the first value goes there so a boundary exists right away.</p><p>After that first insert, the rule is based on a single comparison. Values less than or equal to <code>lower.peek()</code> belong in the lower half, so they go into the max heap. Values greater than <code>lower.peek()</code> belong in the upper half, so they go into the min heap. This keeps the ordering relationship intact, where all values in the lower heap stay less than or equal to all values in the upper heap.</p><p>Putting the placement rule into a helper method keeps it in one place:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;bc39301d-7b93-4518-8ba6-d95434464cec&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">final class PlacementRule {
    public static void placeValue(
            int value,
            PriorityQueue&lt;Integer&gt; lowerMaxHeap,
            PriorityQueue&lt;Integer&gt; upperMinHeap
    ) {
        if (lowerMaxHeap.isEmpty() || value &lt;= lowerMaxHeap.peek()) {
            lowerMaxHeap.add(value);
        } else {
            upperMinHeap.add(value);
        }
    }
}</code></pre></div><p>That helper checks the empty start case, then compares against <code>lowerMaxHeap.peek()</code> to decide which heap receives the new value.</p><h4>Rebalancing After the Insert</h4><p>Size balance follows one rule. The heap size difference cannot exceed one. If it does, one value moves across the boundary.</p><p>If the lower heap grows to more than one item larger than the upper heap, the lower heap has too many values. The correct value to move is the lower heap head from <code>poll()</code>, because it is the largest value in the lower half and sits on the boundary. If the upper heap grows to more than one item larger than the lower heap, the upper heap has too many values. The correct value to move is the upper heap head from <code>poll()</code>, because it is the smallest value in the upper half and also sits on the boundary.</p><p>Only one transfer is needed after a single insert, because each insert changes size by one, and a single move changes the difference by two. The ordering relationship stays intact because the transferred value is a boundary value.</p><h4>Reading the Median After Every Step</h4><p>Median read depends on the combined count and the heap sizes, and it is available immediately after every insert through a <code>getMedian()</code> call. If no values exist yet, there is no median, so throwing an exception is reasonable. If the heaps have the same size, the stream has an even count. The median is the arithmetic mean of the two boundary values, <code>lower.peek()</code> and <code>upper.peek()</code>.</p><p>If one heap has one extra value, the stream has an odd count. The median is the head of the heap with the extra value.</p><p>It&#8217;s worth mentioning that in java, adding two <code>int</code> values can overflow before the division happens. Casting to <code>long</code> before addition avoids that, and dividing by <code>2.0</code> returns a <code>double</code> median.</p><p>Median read logic can live in a small helper and look like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;d44cc491-e2c2-4ace-8556-1a2d7d2b8d51&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">final class MedianRead {
    public static double readMedian(
            PriorityQueue&lt;Integer&gt; lowerMaxHeap,
            PriorityQueue&lt;Integer&gt; upperMinHeap
    ) {
        int total = lowerMaxHeap.size() + upperMinHeap.size();
        if (total == 0) {
            throw new IllegalStateException(&#8221;No values added&#8221;);
        }

        if (lowerMaxHeap.size() == upperMinHeap.size()) {
            long left = lowerMaxHeap.peek();
            long right = upperMinHeap.peek();
            return (left + right) / 2.0;
        }

        return (lowerMaxHeap.size() &gt; upperMinHeap.size())
                ? lowerMaxHeap.peek()
                : upperMinHeap.peek();
    }
}</code></pre></div><p>This method reads without moving anything. Size checks decide which case applies, then <code>peek()</code> pulls the boundary values without changing heap contents. The even-count case casts to <code>long</code> before the add, which avoids overflow when two large <code>int</code> values are summed, and the <code>/ 2.0</code> keeps the return type as <code>double</code> so midpoints like 2.5 stay accurate.</p><h4>Complete Java Implementation</h4><p>To make a complete tracker, keep two <code>PriorityQueue</code> instances, place each new value into the correct heap, rebalance when a heap gets more than one item ahead, then read the median from the heap heads. The implementation below depends on <code>PlacementRule</code> and <code>MedianRead</code> from earlier in the article, so copy those too if you want this section to compile by itself. Insert cost is <code>O(log n)</code> due to heap <code>add()</code> and the possible <code>poll()</code> plus <code>add()</code> during rebalance. Median read cost is <code>O(1)</code> due to size checks and <code>peek()</code>. Space cost is <code>O(n)</code> because both heaps store the full set of inserted values.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;a3ce927b-3e74-4e65-a64a-7690d623ee28&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import java.util.Collections;
import java.util.PriorityQueue;

public final class MedianStreamTracker {
    private final PriorityQueue&lt;Integer&gt; lower =
            new PriorityQueue&lt;&gt;(Collections.reverseOrder()); // max heap
    private final PriorityQueue&lt;Integer&gt; upper =
            new PriorityQueue&lt;&gt;(); // min heap

    public void addValue(int value) {
        placeValue(value);

        if (lower.size() &gt; upper.size() + 1) {
            upper.add(lower.poll());
        } else if (upper.size() &gt; lower.size() + 1) {
            lower.add(upper.poll());
        }
    }

    public double getMedian() {
        return readMedian();
    }

    private void placeValue(int value) {
        PlacementRule.placeValue(value, lower, upper);
    }

    private double readMedian() {
        return MedianRead.readMedian(lower, upper);
    }

    public static void main(String[] args) {
        MedianStreamTracker tracker = new MedianStreamTracker();

        int[] values = {5, 15, 1, 3, 8, 7, 9, 10, 6};
        for (int v : values) {
            tracker.addValue(v);
            System.out.println(&#8221;Value &#8220; + v + &#8220; median &#8220; + tracker.getMedian());
        }
    }
}</code></pre></div><p><code>MedianStreamTracker</code> keeps two heaps as fields so state lives across inserts. <code>lower</code> is built with <code>Collections.reverseOrder()</code> so it behaves like a max heap, and <code>upper</code> stays the default min heap.</p><p><code>addValue</code> does two steps. First it calls <code>placeValue(value)</code>, which pushes the new number into the correct heap based on the current <code>lower.peek()</code> boundary. After that, <code>addValue</code> checks heap sizes and runs a rebalance only when one heap has more than one extra element. When that happens, it transfers one boundary element with a <code>poll()</code> from the larger heap and an <code>add()</code> into the other heap. That transfer is why the size gap cannot grow beyond one for more than a single insert.</p><p><code>getMedian</code> calls <code>readMedian()</code>. That method reads only, meaning it never calls <code>poll()</code> and never changes heap contents. When sizes match, it averages the two boundary values, casting to <code>long</code> before the addition to avoid <code>int</code> overflow, and dividing by <code>2.0</code> so the return stays a <code>double</code>. When sizes differ by one, it returns the <code>peek()</code> from the larger heap, which is the middle value for an odd count.</p><h3>Conclusion</h3><p>The two heaps method keeps the stream split into a lower side and an upper side, with the boundary always visible through <code>peek()</code>. Each insert does two things, it places the value on the correct side based on the lower boundary, then it moves one boundary value across if a heap gets more than one item ahead. After that, median read becomes a quick size check and one or two <code>peek()</code> calls, with the even case averaging the two boundary values safely by casting to <code>long</code> before the add.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/PriorityQueue.html">Java PriorityQueue Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Collections.html">Java Collections Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Comparator.html">Java Comparator Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Collections.html#reverseOrder%28%29">Java Collections.reverseOrder Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Minimum Cost Paths with 0 1 BFS in Java]]></title><description><![CDATA[Shortest path problems come up any time you&#8217;re hunting for the lowest total cost through a network, a grid, a dependency graph, or a routing table.]]></description><link>https://alexanderobregon.substack.com/p/minimum-cost-paths-with-0-1-bfs-in</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/minimum-cost-paths-with-0-1-bfs-in</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sun, 01 Mar 2026 18:20:18 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!quZI!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!quZI!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!quZI!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!quZI!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!quZI!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!quZI!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!quZI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d3a39509-2238-4060-b6b3-33a5734952d1_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!quZI!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!quZI!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!quZI!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!quZI!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd3a39509-2238-4060-b6b3-33a5734952d1_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Shortest path problems come up any time you&#8217;re hunting for the lowest total cost through a network, a grid, a dependency graph, or a routing table. Dijkstra&#8217;s algorithm is the usual tool, but the priority queue adds a log factor that you pay on every push and pop. With edge weights limited to 0 or 1, 0 1 BFS gives the same shortest distances as Dijkstra while swapping the priority queue for a deque, so the runtime stays linear in the size of the graph.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>What 0 1 BFS Solves</h3><p>0 1 BFS targets shortest path problems where every edge cost is either 0 or 1. That small rule changes how you can schedule work, because distance values grow in steps of zero or one rather than jumping by larger amounts. The result is the same minimum total cost you&#8217;d get from Dijkstra, but the queueing behavior can be handled with a deque.</p><h4>Weight Restriction and What it Buys You</h4><p>Edge weights limited to 0 and 1 sound narrow, yet they show up a lot in practice. Think about decisions that are free versus decisions that cost one unit, or state transitions where one action is preferred and the other is a penalty of one. Once costs stay in that two value world, every move from a node with distance <code>d</code> lands you at either <code>d</code> again through a 0 edge or <code>d + 1</code> through a 1 edge. That tight distance growth is the entire reason 0 1 BFS exists. Dijkstra&#8217;s algorithm handles any nonnegative weights by always pulling the smallest known distance from a priority queue. The priority queue has to keep itself ordered, so each insert and extract pays a log factor. With 0 and 1 weights, you can keep the work ordered without a full heap, because new distances can only match the current distance or be exactly one larger than it. A deque matches that behavior with constant time pushes and pops at both ends.</p><p>One way to internalize the restriction is to look at a tiny graph and see what the distances mean. The cost is literally the count of 1 edges along the cheapest route, because 0 edges do not add anything.</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!M0XE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!M0XE!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png 424w, https://substackcdn.com/image/fetch/$s_!M0XE!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png 848w, https://substackcdn.com/image/fetch/$s_!M0XE!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png 1272w, https://substackcdn.com/image/fetch/$s_!M0XE!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!M0XE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png" width="1456" height="207" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:207,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:84783,&quot;alt&quot;:&quot;// Distances are counts of 1-edges on the cheapest route. // 0 -> 1 costs 0 // 1 -> 2 costs 0 // 0 -> 2 costs 1 int distTo2ViaZeroEdges = 0; // 0->1->2 uses two edges, total cost 0 int distTo2Direct = 1;       // 0->2 uses one edge, total cost 1&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188647037?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="// Distances are counts of 1-edges on the cheapest route. // 0 -> 1 costs 0 // 1 -> 2 costs 0 // 0 -> 2 costs 1 int distTo2ViaZeroEdges = 0; // 0->1->2 uses two edges, total cost 0 int distTo2Direct = 1;       // 0->2 uses one edge, total cost 1" title="// Distances are counts of 1-edges on the cheapest route. // 0 -> 1 costs 0 // 1 -> 2 costs 0 // 0 -> 2 costs 1 int distTo2ViaZeroEdges = 0; // 0->1->2 uses two edges, total cost 0 int distTo2Direct = 1;       // 0->2 uses one edge, total cost 1" srcset="https://substackcdn.com/image/fetch/$s_!M0XE!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png 424w, https://substackcdn.com/image/fetch/$s_!M0XE!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png 848w, https://substackcdn.com/image/fetch/$s_!M0XE!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png 1272w, https://substackcdn.com/image/fetch/$s_!M0XE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F509df851-891e-4830-9f9c-8c596a0fcad8_1735x247.png 1456w" sizes="100vw"></picture><div></div></div></a></figure></div><p>That example is intentionally small, but the idea scales. You still measure total weight, not edge count. Paths with more hops can win when they avoid 1 edges.</p><h4>Deque Ordering That Matches Shortest Distance</h4><p>Deque ordering is the whole trick. The deque holds nodes that still need their outgoing edges processed. The front of the deque represents the next best distance work you know about, while the back holds work that is one cost step behind.</p><p>When an edge of weight 0 improves a neighbor, the neighbor ends up with the same distance as the current node. It belongs right up front because it competes with nodes already sitting at that distance. When an edge of weight 1 improves a neighbor, the neighbor lands at distance <code>dist[u] + 1</code>. That belongs at the back because every node already reachable at the current distance should be handled first.</p><p>The deque operations map directly to that idea. Java&#8217;s <code>ArrayDeque</code> gives the calls you want, like this:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!SaFm!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!SaFm!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png 424w, https://substackcdn.com/image/fetch/$s_!SaFm!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png 848w, https://substackcdn.com/image/fetch/$s_!SaFm!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png 1272w, https://substackcdn.com/image/fetch/$s_!SaFm!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!SaFm!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png" width="1456" height="458" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:458,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:87953,&quot;alt&quot;:&quot;// Distances are counts of 1-edges on the cheapest route. // 0 -> 1 costs 0 // 1 -> 2 costs 0 // 0 -> 2 costs 1 int distTo2ViaZeroEdges = 0; // 0->1->2 uses two edges, total cost 0 int distTo2Direct = 1;       // 0->2 uses one edge, total cost 1&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188647037?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="// Distances are counts of 1-edges on the cheapest route. // 0 -> 1 costs 0 // 1 -> 2 costs 0 // 0 -> 2 costs 1 int distTo2ViaZeroEdges = 0; // 0->1->2 uses two edges, total cost 0 int distTo2Direct = 1;       // 0->2 uses one edge, total cost 1" title="// Distances are counts of 1-edges on the cheapest route. // 0 -> 1 costs 0 // 1 -> 2 costs 0 // 0 -> 2 costs 1 int distTo2ViaZeroEdges = 0; // 0->1->2 uses two edges, total cost 0 int distTo2Direct = 1;       // 0->2 uses one edge, total cost 1" srcset="https://substackcdn.com/image/fetch/$s_!SaFm!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png 424w, https://substackcdn.com/image/fetch/$s_!SaFm!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png 848w, https://substackcdn.com/image/fetch/$s_!SaFm!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png 1272w, https://substackcdn.com/image/fetch/$s_!SaFm!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2fca8226-3b0d-4e6f-8926-4c14873714ac_1734x545.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Those three lines capture the ordering rule without any sorting or heap bookkeeping. The ordering works because the only distances that get introduced from a node at distance <code>d</code> are <code>d</code> and <code>d + 1</code>. In a broader weight range, pushing to front or back would not keep the deque aligned with distance order.</p><p>Something that helps this feel less mysterious is to thinks of the deque as holding two bands of work at any moment. The front band is distance <code>d</code>, and the back band is distance <code>d + 1</code>. When the front band empties, the <code>d + 1</code> band naturally becomes the new front band. That shift happens without moving elements around, because the deque already holds them in that order.</p><h4>Why the Result Matches the Minimum Cost</h4><p>This follows the same general story as Dijkstra. Keep a distance array <code>dist[]</code> where <code>dist[x]</code> is the best cost found so far from the source to <code>x</code>. Start with <code>dist[source] = 0</code> and all other entries set to a large number. The deque starts with the source.</p><p>The loop repeatedly takes the node from the front of the deque and tries to improve its neighbors. Improving a neighbor means finding a smaller value than what is stored in <code>dist[neighbor]</code>. When that happens, store the new value and push the neighbor into the deque at the front or back based on edge weight.</p><p>Let&#8217;s see the core rule in the smallest useful form, without dragging in a full implementation:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!W34z!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!W34z!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png 424w, https://substackcdn.com/image/fetch/$s_!W34z!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png 848w, https://substackcdn.com/image/fetch/$s_!W34z!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png 1272w, https://substackcdn.com/image/fetch/$s_!W34z!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!W34z!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png" width="1456" height="212" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/fe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:212,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:36885,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188647037?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!W34z!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png 424w, https://substackcdn.com/image/fetch/$s_!W34z!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png 848w, https://substackcdn.com/image/fetch/$s_!W34z!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png 1272w, https://substackcdn.com/image/fetch/$s_!W34z!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffe72084f-eeb8-4674-baf4-6e34bedbae85_1721x250.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>The main question beginners ask is why it is safe to pop from the front without a priority queue. The answer lives in the distance steps, popping from the front always gives you some node with the smallest distance currently reachable through the deque rules. Any time a node gets a distance equal to the current best frontier distance, it gets pushed to the front, so it cannot get stuck behind nodes with larger distance. Any time a node gets a distance that is one larger, it goes to the back, which is exactly where the next distance layer belongs.</p><p>It also helps to know what you do not need. There is no requirement to mark a node final and refuse future updates. A node can appear in the deque more than once, because multiple routes can discover it. The distance check blocks stale work naturally. If a later pop reaches a node <code>u</code> after <code>dist[u]</code> has already been improved through a better route, the relax attempts compute <code>nd</code> values that do not beat the current <code>dist[]</code> entries, so nothing changes and the loop moves on.</p><p>The final state is what you want. Every <code>dist[x]</code> is the minimum total cost from the source to <code>x</code>, measured as the smallest sum of 0 and 1 weights along any route.</p><h3>Mechanics in Java</h3><p>Java gives you everything needed for 0 1 BFS without any special libraries. The work comes down to picking the right containers, sticking to a consistent distance rule, and keeping graph input in a form that is fast to traverse.</p><h4>Data Structures for Graph Storage</h4><p>Adjacency lists fit well for this algorithm. Each node stores a collection of outgoing edges, and each edge stores a destination plus a weight that must be 0 or 1. With nodes labeled <code>0</code> through <code>n - 1</code>, <code>List&lt;Edge&gt;[]</code> is a common layout and performs well for scanning neighbors. Distances live in an <code>int[] dist</code> array. Initialize every value to a large sentinel, then set the source to 0. The deque tracks which node gets processed next, and <code>ArrayDeque</code> is the standard choice because it supports fast operations at both ends.</p><p>This code sets up a lightweight edge type and graph creation helpers:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!mkr3!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!mkr3!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png 424w, https://substackcdn.com/image/fetch/$s_!mkr3!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png 848w, https://substackcdn.com/image/fetch/$s_!mkr3!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png 1272w, https://substackcdn.com/image/fetch/$s_!mkr3!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!mkr3!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png" width="1200" height="677.4725274725274" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:822,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:156513,&quot;alt&quot;:&quot;import java.util.ArrayList; import java.util.List;  public class GraphSetup {      public static final class Edge {         public final int to;         public final int w;          public Edge(int to, int w) {             if (w != 0 &amp;&amp; w != 1) {                 throw new IllegalArgumentException(\&quot;weight must be 0 or 1\&quot;);             }             this.to = to;             this.w = w;         }     }      @SuppressWarnings(\&quot;unchecked\&quot;)     public static List<Edge>[] newGraph(int n) {         List<Edge>[] g = new List[n];         for (int i = 0; i < n; i++) {             g[i] = new ArrayList<>();         }         return g;     }      public static void addDirected(List<Edge>[] g, int from, int to, int w) {         g[from].add(new Edge(to, w));     }      public static void addUndirected(List<Edge>[] g, int a, int b, int w) {         g[a].add(new Edge(b, w));         g[b].add(new Edge(a, w));     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188647037?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import java.util.ArrayList; import java.util.List;  public class GraphSetup {      public static final class Edge {         public final int to;         public final int w;          public Edge(int to, int w) {             if (w != 0 &amp;&amp; w != 1) {                 throw new IllegalArgumentException(&quot;weight must be 0 or 1&quot;);             }             this.to = to;             this.w = w;         }     }      @SuppressWarnings(&quot;unchecked&quot;)     public static List<Edge>[] newGraph(int n) {         List<Edge>[] g = new List[n];         for (int i = 0; i < n; i++) {             g[i] = new ArrayList<>();         }         return g;     }      public static void addDirected(List<Edge>[] g, int from, int to, int w) {         g[from].add(new Edge(to, w));     }      public static void addUndirected(List<Edge>[] g, int a, int b, int w) {         g[a].add(new Edge(b, w));         g[b].add(new Edge(a, w));     } }" title="import java.util.ArrayList; import java.util.List;  public class GraphSetup {      public static final class Edge {         public final int to;         public final int w;          public Edge(int to, int w) {             if (w != 0 &amp;&amp; w != 1) {                 throw new IllegalArgumentException(&quot;weight must be 0 or 1&quot;);             }             this.to = to;             this.w = w;         }     }      @SuppressWarnings(&quot;unchecked&quot;)     public static List<Edge>[] newGraph(int n) {         List<Edge>[] g = new List[n];         for (int i = 0; i < n; i++) {             g[i] = new ArrayList<>();         }         return g;     }      public static void addDirected(List<Edge>[] g, int from, int to, int w) {         g[from].add(new Edge(to, w));     }      public static void addUndirected(List<Edge>[] g, int a, int b, int w) {         g[a].add(new Edge(b, w));         g[b].add(new Edge(a, w));     } }" srcset="https://substackcdn.com/image/fetch/$s_!mkr3!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png 424w, https://substackcdn.com/image/fetch/$s_!mkr3!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png 848w, https://substackcdn.com/image/fetch/$s_!mkr3!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png 1272w, https://substackcdn.com/image/fetch/$s_!mkr3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2296defe-a799-44b3-844c-3d218cae2c34_1792x1012.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Directed versus undirected is decided by how you insert edges. The search logic does not change.</p><h4>Core Implementation for 0 1 BFS</h4><p>The loop always does the same thing. Pop the next node from the front of the deque, then try to improve distances for neighbors by relaxing outgoing edges. Relaxing means checking if <code>dist[u] + w</code> beats the stored distance for the neighbor. When it does, store the new distance and push the neighbor to the front for weight 0 or to the back for weight 1.</p><p>This version returns the full distance array from a source node:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!WJJe!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!WJJe!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png 424w, https://substackcdn.com/image/fetch/$s_!WJJe!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png 848w, https://substackcdn.com/image/fetch/$s_!WJJe!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png 1272w, https://substackcdn.com/image/fetch/$s_!WJJe!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!WJJe!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png" width="1200" height="710.4395604395604" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:862,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:144073,&quot;alt&quot;:&quot;import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque; import java.util.List;  public class ZeroOneBfsCore {      public static int[] shortestPaths(List<GraphSetup.Edge>[] graph, int source) {         int n = graph.length;         int INF = 1_000_000_000;          int[] dist = new int[n];         Arrays.fill(dist, INF);         dist[source] = 0;          Deque<Integer> dq = new ArrayDeque<>();         dq.addFirst(source);          while (!dq.isEmpty()) {             int u = dq.removeFirst();             int du = dist[u];              for (GraphSetup.Edge e : graph[u]) {                 int v = e.to;                 int nd = du + e.w;                  if (nd < dist[v]) {                     dist[v] = nd;                      if (e.w == 0) {                         dq.addFirst(v);                     } else {                         dq.addLast(v);                     }                 }             }         }          return dist;     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188647037?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque; import java.util.List;  public class ZeroOneBfsCore {      public static int[] shortestPaths(List<GraphSetup.Edge>[] graph, int source) {         int n = graph.length;         int INF = 1_000_000_000;          int[] dist = new int[n];         Arrays.fill(dist, INF);         dist[source] = 0;          Deque<Integer> dq = new ArrayDeque<>();         dq.addFirst(source);          while (!dq.isEmpty()) {             int u = dq.removeFirst();             int du = dist[u];              for (GraphSetup.Edge e : graph[u]) {                 int v = e.to;                 int nd = du + e.w;                  if (nd < dist[v]) {                     dist[v] = nd;                      if (e.w == 0) {                         dq.addFirst(v);                     } else {                         dq.addLast(v);                     }                 }             }         }          return dist;     } }" title="import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque; import java.util.List;  public class ZeroOneBfsCore {      public static int[] shortestPaths(List<GraphSetup.Edge>[] graph, int source) {         int n = graph.length;         int INF = 1_000_000_000;          int[] dist = new int[n];         Arrays.fill(dist, INF);         dist[source] = 0;          Deque<Integer> dq = new ArrayDeque<>();         dq.addFirst(source);          while (!dq.isEmpty()) {             int u = dq.removeFirst();             int du = dist[u];              for (GraphSetup.Edge e : graph[u]) {                 int v = e.to;                 int nd = du + e.w;                  if (nd < dist[v]) {                     dist[v] = nd;                      if (e.w == 0) {                         dq.addFirst(v);                     } else {                         dq.addLast(v);                     }                 }             }         }          return dist;     } }" srcset="https://substackcdn.com/image/fetch/$s_!WJJe!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png 424w, https://substackcdn.com/image/fetch/$s_!WJJe!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png 848w, https://substackcdn.com/image/fetch/$s_!WJJe!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png 1272w, https://substackcdn.com/image/fetch/$s_!WJJe!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa8042d7c-f6b3-4062-9f66-9d486a724bad_1799x1065.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><code>INF</code> is just a large placeholder so every node starts out as unreachable, then <code>dist[source] = 0</code> marks the starting point as free to reach; the deque begins with that source so the loop has somewhere to start. Each time <code>u</code> is pulled from the front, <code>du</code> captures the best known cost to reach <code>u</code>, and the <code>for</code> loop scans every outgoing edge <code>e</code> to see if going from <code>u</code> to <code>v</code> improves <code>dist[v]</code>. The line <code>nd = du + e.w</code> is the whole cost rule, and the <code>if (nd &lt; dist[v])</code> check is what prevents worse routes from overwriting better ones. When an improvement happens, pushing <code>v</code> to the front for weight 0 keeps same cost work moving immediately, while pushing to the back for weight 1 delays that work behind all nodes that are already reachable with the current best cost.</p><p>Sometimes only one destination matters, not the full set of distances. Early exit works when the target is removed from the front of the deque, because nodes are processed in nondecreasing distance order with the deque rule. That keeps the result the same while skipping work that would only improve distances to other nodes.</p><p>This version returns the minimum cost to a target node:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!oWM8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!oWM8!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png 424w, https://substackcdn.com/image/fetch/$s_!oWM8!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png 848w, https://substackcdn.com/image/fetch/$s_!oWM8!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png 1272w, https://substackcdn.com/image/fetch/$s_!oWM8!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!oWM8!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png" width="1200" height="717.032967032967" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:870,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:150184,&quot;alt&quot;:&quot;import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque; import java.util.List;  public class ZeroOneBfsToTarget {      public static int shortestPathTo(List<GraphSetup.Edge>[] graph, int source, int target) {         int n = graph.length;         int INF = 1_000_000_000;          int[] dist = new int[n];         Arrays.fill(dist, INF);         dist[source] = 0;          Deque<Integer> dq = new ArrayDeque<>();         dq.addFirst(source);          while (!dq.isEmpty()) {             int u = dq.removeFirst();             if (u == target) {                 return dist[u];             }              int du = dist[u];             for (GraphSetup.Edge e : graph[u]) {                 int v = e.to;                 int nd = du + e.w;                  if (nd < dist[v]) {                     dist[v] = nd;                     if (e.w == 0) dq.addFirst(v);                     else dq.addLast(v);                 }             }         }          return INF;     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188647037?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque; import java.util.List;  public class ZeroOneBfsToTarget {      public static int shortestPathTo(List<GraphSetup.Edge>[] graph, int source, int target) {         int n = graph.length;         int INF = 1_000_000_000;          int[] dist = new int[n];         Arrays.fill(dist, INF);         dist[source] = 0;          Deque<Integer> dq = new ArrayDeque<>();         dq.addFirst(source);          while (!dq.isEmpty()) {             int u = dq.removeFirst();             if (u == target) {                 return dist[u];             }              int du = dist[u];             for (GraphSetup.Edge e : graph[u]) {                 int v = e.to;                 int nd = du + e.w;                  if (nd < dist[v]) {                     dist[v] = nd;                     if (e.w == 0) dq.addFirst(v);                     else dq.addLast(v);                 }             }         }          return INF;     } }" title="import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque; import java.util.List;  public class ZeroOneBfsToTarget {      public static int shortestPathTo(List<GraphSetup.Edge>[] graph, int source, int target) {         int n = graph.length;         int INF = 1_000_000_000;          int[] dist = new int[n];         Arrays.fill(dist, INF);         dist[source] = 0;          Deque<Integer> dq = new ArrayDeque<>();         dq.addFirst(source);          while (!dq.isEmpty()) {             int u = dq.removeFirst();             if (u == target) {                 return dist[u];             }              int du = dist[u];             for (GraphSetup.Edge e : graph[u]) {                 int v = e.to;                 int nd = du + e.w;                  if (nd < dist[v]) {                     dist[v] = nd;                     if (e.w == 0) dq.addFirst(v);                     else dq.addLast(v);                 }             }         }          return INF;     } }" srcset="https://substackcdn.com/image/fetch/$s_!oWM8!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png 424w, https://substackcdn.com/image/fetch/$s_!oWM8!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png 848w, https://substackcdn.com/image/fetch/$s_!oWM8!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png 1272w, https://substackcdn.com/image/fetch/$s_!oWM8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F013b76a5-6ca0-49f3-8b33-7fb5e5062b3a_1790x1070.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The only new behavior is the early return at <code>if (u == target) return dist[u];</code>, which takes advantage of deque ordering. After <code>target</code> reaches the front, the cost stored in <code>dist[target]</code> cannot be beaten by some node sitting deeper in the deque, because deeper entries are either at the same cost but were placed behind by earlier pushes, or they sit at a higher cost after 1 weight pushes. The rest of the method matches the same distance update rule as the full version, so it still finds the minimum cost to the target, it just stops scanning edges once that target cost is settled.</p><h4>Grid Problems as 0 1 Graphs</h4><p>Grids work well because each cell maps naturally to a node, and each move to a neighbor maps to an edge. One common interpretation is that stepping into a cell costs 0 or 1, based on the destination cell value. The starting cell begins at distance 0, and every move adds the entry cost of the destination cell. Neighbors can be computed on the fly instead of stored in an adjacency list. That keeps memory lower and keeps the loop focused on scanning nearby cells.</p><p>This method computes the minimum cost to every cell from the top left. Each grid entry must be 0 or 1, and its value is treated as the cost to enter that cell:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!mKTE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!mKTE!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png 424w, https://substackcdn.com/image/fetch/$s_!mKTE!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png 848w, https://substackcdn.com/image/fetch/$s_!mKTE!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png 1272w, https://substackcdn.com/image/fetch/$s_!mKTE!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!mKTE!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png" width="1200" height="707.1428571428571" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:858,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:160124,&quot;alt&quot;:&quot;import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque;  public class ZeroOneBfsGridWalk {      public static int[][] minCost(int[][] grid) {         int rows = grid.length;         int cols = grid[0].length;         int INF = 1_000_000_000;          int[][] dist = new int[rows][cols];         for (int r = 0; r < rows; r++) {             Arrays.fill(dist[r], INF);         }         dist[0][0] = 0;          Deque<int[]> dq = new ArrayDeque<>();         dq.addFirst(new int[]{0, 0});          int[] dr = {-1, 1, 0, 0};         int[] dc = {0, 0, -1, 1};          while (!dq.isEmpty()) {             int[] cur = dq.removeFirst();             int r = cur[0];             int c = cur[1];             int base = dist[r][c];              for (int i = 0; i < 4; i++) {                 int nr = r + dr[i];                 int nc = c + dc[i];                 if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;                  int w = grid[nr][nc];                 if (w != 0 &amp;&amp; w != 1) {                     throw new IllegalArgumentException(\&quot;grid values must be 0 or 1\&quot;);                 }                  int nd = base + w;                 if (nd < dist[nr][nc]) {                     dist[nr][nc] = nd;                     if (w == 0) dq.addFirst(new int[]{nr, nc});                     else dq.addLast(new int[]{nr, nc});                 }             }         }          return dist;     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188647037?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque;  public class ZeroOneBfsGridWalk {      public static int[][] minCost(int[][] grid) {         int rows = grid.length;         int cols = grid[0].length;         int INF = 1_000_000_000;          int[][] dist = new int[rows][cols];         for (int r = 0; r < rows; r++) {             Arrays.fill(dist[r], INF);         }         dist[0][0] = 0;          Deque<int[]> dq = new ArrayDeque<>();         dq.addFirst(new int[]{0, 0});          int[] dr = {-1, 1, 0, 0};         int[] dc = {0, 0, -1, 1};          while (!dq.isEmpty()) {             int[] cur = dq.removeFirst();             int r = cur[0];             int c = cur[1];             int base = dist[r][c];              for (int i = 0; i < 4; i++) {                 int nr = r + dr[i];                 int nc = c + dc[i];                 if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;                  int w = grid[nr][nc];                 if (w != 0 &amp;&amp; w != 1) {                     throw new IllegalArgumentException(&quot;grid values must be 0 or 1&quot;);                 }                  int nd = base + w;                 if (nd < dist[nr][nc]) {                     dist[nr][nc] = nd;                     if (w == 0) dq.addFirst(new int[]{nr, nc});                     else dq.addLast(new int[]{nr, nc});                 }             }         }          return dist;     } }" title="import java.util.ArrayDeque; import java.util.Arrays; import java.util.Deque;  public class ZeroOneBfsGridWalk {      public static int[][] minCost(int[][] grid) {         int rows = grid.length;         int cols = grid[0].length;         int INF = 1_000_000_000;          int[][] dist = new int[rows][cols];         for (int r = 0; r < rows; r++) {             Arrays.fill(dist[r], INF);         }         dist[0][0] = 0;          Deque<int[]> dq = new ArrayDeque<>();         dq.addFirst(new int[]{0, 0});          int[] dr = {-1, 1, 0, 0};         int[] dc = {0, 0, -1, 1};          while (!dq.isEmpty()) {             int[] cur = dq.removeFirst();             int r = cur[0];             int c = cur[1];             int base = dist[r][c];              for (int i = 0; i < 4; i++) {                 int nr = r + dr[i];                 int nc = c + dc[i];                 if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;                  int w = grid[nr][nc];                 if (w != 0 &amp;&amp; w != 1) {                     throw new IllegalArgumentException(&quot;grid values must be 0 or 1&quot;);                 }                  int nd = base + w;                 if (nd < dist[nr][nc]) {                     dist[nr][nc] = nd;                     if (w == 0) dq.addFirst(new int[]{nr, nc});                     else dq.addLast(new int[]{nr, nc});                 }             }         }          return dist;     } }" srcset="https://substackcdn.com/image/fetch/$s_!mKTE!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png 424w, https://substackcdn.com/image/fetch/$s_!mKTE!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png 848w, https://substackcdn.com/image/fetch/$s_!mKTE!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png 1272w, https://substackcdn.com/image/fetch/$s_!mKTE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe34e8c2f-1627-4bd1-86a9-3cdf0cfe168c_1791x1055.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>This grid form runs the same algorithm as the adjacency list form. The only difference is where neighbors come from. Instead of iterating stored edges, it checks up to four directions and treats each valid move as an edge with weight 0 or 1.</p><h4>Time and Space Complexity in Big O</h4><p>Using <code>V</code> for the number of nodes and <code>E</code> for the number of edges.</p><p>Runtime is <code>O(V + E)</code>. Each edge relaxation attempt is constant work, and the algorithm only enqueues a node when <code>dist[v]</code> is improved. That keeps total deque operations proportional to the number of successful improvements, which stays within <code>O(E)</code> in this setting. Combined with scanning adjacency lists, the total work stays <code>O(V + E)</code>.</p><p>Space cost is <code>O(V + E)</code> for the adjacency list form. The distance array takes <code>O(V)</code>, the deque can grow to <code>O(V)</code>, and the adjacency structure takes <code>O(V + E)</code>.</p><p>For a grid with <code>rows * cols</code> cells, <code>V = rows * cols</code>. With four directional movement, <code>E</code> stays proportional to <code>V</code>, so runtime stays linear in the number of cells and space stays linear as well.</p><h3>Conclusion</h3><p>0 1 BFS works because the distance values can only stay the same or go up by one when you cross an edge, so the deque naturally keeps the next best work at the front without any heap ordering. The distance array starts with a large sentinel, the source begins at zero, and every relaxation checks <code>dist[u] + w</code> against <code>dist[v]</code> before writing an update. Weight 0 updates go to the front with <code>addFirst</code>, weight 1 updates go to the back with <code>addLast</code>, and that single rule keeps nodes flowing in nondecreasing cost order. From there, the graph version and the grid version follow the same loop, just with different neighbor access, and the runtime stays <code>O(V + E)</code> with space <code>O(V + E)</code> in adjacency list form or linear in the number of grid cells when neighbors are generated on the fly.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Deque.html">Deque Interface Java SE Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayDeque.html">ArrayDeque Class Java SE Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html">List Interface Java SE Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Arrays.html">Arrays Class Java SE Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayList.html">ArrayList Class Java SE Documentation</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Checking Bipartite Graphs with BFS in Java]]></title><description><![CDATA[Graphs pop up anywhere you have connections between things, like service dependencies, network links, or rules where two items can&#8217;t end up in the same bucket.]]></description><link>https://alexanderobregon.substack.com/p/checking-bipartite-graphs-with-bfs</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/checking-bipartite-graphs-with-bfs</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sat, 28 Feb 2026 18:11:17 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!q-8V!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!q-8V!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!q-8V!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!q-8V!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!q-8V!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!q-8V!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!q-8V!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!q-8V!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!q-8V!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!q-8V!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!q-8V!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4f62931c-04ac-4873-b5e1-9c22bd10fbbb_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Graphs pop up anywhere you have connections between things, like service dependencies, network links, or rules where two items can&#8217;t end up in the same bucket. Bipartite checks show up most in that last case, because bipartite means you can split vertices into two groups and every edge goes across the split, never staying within one group. BFS fits nicely here because it visits vertices in waves from a starting point, so assigning one color to the start and flipping the color as you step across edges stays consistent all the way through the traversal.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>What Bipartite Actually Means</h3><p>Bipartite is a property that puts a strict rule on edges. When the property holds, every connection can be seen as linking across two groups, never staying inside the same group. Thinking in terms of group membership helps later because the check comes down to keeping one consistent rule for every edge you read.</p><h4>The Two Set Rule</h4><p>Bipartite means the vertices can be split into two sets where every edge goes from one set to the other. Put into everyday terms, every vertex gets one of two labels, and any pair of adjacent vertices must always end up with opposite labels. That label idea is why two-color language shows up so much. A practical way to represent the two sets is to store <code>1</code> for one side and <code>-1</code> for the other side. An edge from <code>u</code> to <code>v</code> then carries a single requirement. If <code>u</code> has <code>1</code>, <code>v</code> must have <code>-1</code>, and if <code>u</code> has <code>-1</code>, <code>v</code> must have <code>1</code>.</p><p>Graphs with no edges are bipartite because there are no constraints to violate. Trees are bipartite because there are no cycles, so alternating labels as you move outward never forces you back into a conflicting requirement. Disconnected graphs can still be bipartite too, because the same two-set rule can be applied separately to each connected component.</p><p>Some graph details are worth calling out because they affect the rule immediately. Self-loops, meaning an edge from a vertex back to itself, make the graph non-bipartite right away. The edge would require the vertex to be opposite of itself, and that requirement cannot be satisfied. Parallel edges, meaning repeated edges between the same two vertices, do not change the outcome. They repeat the same requirement and either remain consistent or repeat the same conflict.</p><p>This helper shows the rule an edge has to follow, without doing any BFS work yet:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!FLtX!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!FLtX!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png 424w, https://substackcdn.com/image/fetch/$s_!FLtX!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png 848w, https://substackcdn.com/image/fetch/$s_!FLtX!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png 1272w, https://substackcdn.com/image/fetch/$s_!FLtX!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!FLtX!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png" width="932" height="241.96153846153845" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:378,&quot;width&quot;:1456,&quot;resizeWidth&quot;:932,&quot;bytes&quot;:103058,&quot;alt&quot;:&quot;public final class BipartiteRules {      // color[i] uses 0 for unassigned and 1 or -1 after assignment     public static boolean edgeRespectsTwoSets(int[] color, int u, int v) {         if (u == v) {             return false; // self-loop forces a vertex to oppose itself         }         if (color[u] == 0 || color[v] == 0) {             return true;  // no conflict can be proven until both endpoints are assigned         }         return color[u] == -color[v];     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188640722?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="public final class BipartiteRules {      // color[i] uses 0 for unassigned and 1 or -1 after assignment     public static boolean edgeRespectsTwoSets(int[] color, int u, int v) {         if (u == v) {             return false; // self-loop forces a vertex to oppose itself         }         if (color[u] == 0 || color[v] == 0) {             return true;  // no conflict can be proven until both endpoints are assigned         }         return color[u] == -color[v];     } }" title="public final class BipartiteRules {      // color[i] uses 0 for unassigned and 1 or -1 after assignment     public static boolean edgeRespectsTwoSets(int[] color, int u, int v) {         if (u == v) {             return false; // self-loop forces a vertex to oppose itself         }         if (color[u] == 0 || color[v] == 0) {             return true;  // no conflict can be proven until both endpoints are assigned         }         return color[u] == -color[v];     } }" srcset="https://substackcdn.com/image/fetch/$s_!FLtX!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png 424w, https://substackcdn.com/image/fetch/$s_!FLtX!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png 848w, https://substackcdn.com/image/fetch/$s_!FLtX!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png 1272w, https://substackcdn.com/image/fetch/$s_!FLtX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a7d767c-4767-4731-ad86-42926c00cdd9_1762x457.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Unassigned values are treated as unknown, which matches how the reasoning works. Until both endpoints have labels, the edge cannot contradict anything yet. The moment both endpoints are labeled, the edge either agrees with the opposite-label rule or it does not.</p><h4>Odd Cycles Create a Contradiction</h4><p>Odd-length cycles are the classic reason a graph fails the bipartite test. The reason comes straight from the opposite-label rule. Moving across one edge flips the label. Moving across two edges flips twice and returns to the starting label. Moving across three edges flips again and lands on the opposite label, and that parity difference is what causes trouble when a cycle closes.</p><p>Take a cycle with three vertices. Start by assigning the first vertex label <code>1</code>. The next vertex must be <code>-1</code>. The third vertex must be <code>1</code>. When the cycle closes back to the first vertex, the closing edge demands the first vertex be <code>-1</code> as well, which clashes with the original assignment. That is the entire contradiction. The same logic holds for any odd cycle length, not just triangles. Even-length cycles do not create that conflict, because an even number of flips returns you to the original label. Squares, hexagons, and other even cycles can be labeled with two sets without forcing an endpoint to change its label after the cycle closes.</p><p>This tiny utility keeps the parity idea visible without getting into traversal:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Ue39!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Ue39!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png 424w, https://substackcdn.com/image/fetch/$s_!Ue39!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png 848w, https://substackcdn.com/image/fetch/$s_!Ue39!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png 1272w, https://substackcdn.com/image/fetch/$s_!Ue39!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Ue39!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png" width="796" height="193.53296703296704" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f20b8823-e14d-424a-9810-419d689f6d46_1728x420.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:354,&quot;width&quot;:1456,&quot;resizeWidth&quot;:796,&quot;bytes&quot;:97103,&quot;alt&quot;:&quot;public final class CycleParity {      // cycleLength is the number of edges in the cycle     public static boolean oddCycleForcesConflict(int cycleLength) {         if (cycleLength <= 0) {             throw new IllegalArgumentException(\&quot;cycleLength must be positive\&quot;);         }         return (cycleLength % 2) == 1;     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188640722?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="public final class CycleParity {      // cycleLength is the number of edges in the cycle     public static boolean oddCycleForcesConflict(int cycleLength) {         if (cycleLength <= 0) {             throw new IllegalArgumentException(&quot;cycleLength must be positive&quot;);         }         return (cycleLength % 2) == 1;     } }" title="public final class CycleParity {      // cycleLength is the number of edges in the cycle     public static boolean oddCycleForcesConflict(int cycleLength) {         if (cycleLength <= 0) {             throw new IllegalArgumentException(&quot;cycleLength must be positive&quot;);         }         return (cycleLength % 2) == 1;     } }" srcset="https://substackcdn.com/image/fetch/$s_!Ue39!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png 424w, https://substackcdn.com/image/fetch/$s_!Ue39!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png 848w, https://substackcdn.com/image/fetch/$s_!Ue39!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png 1272w, https://substackcdn.com/image/fetch/$s_!Ue39!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff20b8823-e14d-424a-9810-419d689f6d46_1728x420.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>Odd cycles can sit inside much larger graphs, and the full graph does not need to be one big cycle to fail the bipartite check. One odd cycle anywhere in a component is enough, because eventually the labeling logic reaches a closing edge that requires a vertex to take a second label that disagrees with the one it already has.</p><p>Seeing the contradiction play out with explicit assignments helps cement the idea. This example hard-codes a triangle and assigns labels step by step, then checks the closing edge:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!qx4U!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!qx4U!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png 424w, https://substackcdn.com/image/fetch/$s_!qx4U!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png 848w, https://substackcdn.com/image/fetch/$s_!qx4U!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png 1272w, https://substackcdn.com/image/fetch/$s_!qx4U!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!qx4U!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png" width="946" height="455.4574175824176" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:701,&quot;width&quot;:1456,&quot;resizeWidth&quot;:946,&quot;bytes&quot;:172064,&quot;alt&quot;:&quot;import java.util.Arrays;  public class OddCycleContradictionDemo {      public static void main(String[] args) {         int n = 3;         int[] color = new int[n]; // 0 unassigned, 1 and -1 are the two labels          // Triangle edges: 0-1, 1-2, 2-0         color[0] = 1;          // Edge 0-1 forces 1 to be opposite of 0         color[1] = -color[0];          // Edge 1-2 forces 2 to be opposite of 1         color[2] = -color[1];          // Closing edge 2-0 requires 0 to be opposite of 2         boolean closingEdgeOk = (color[0] == -color[2]);          System.out.println(\&quot;Colors \&quot; + Arrays.toString(color));         System.out.println(\&quot;Closing edge 2-0 valid \&quot; + closingEdgeOk);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188640722?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import java.util.Arrays;  public class OddCycleContradictionDemo {      public static void main(String[] args) {         int n = 3;         int[] color = new int[n]; // 0 unassigned, 1 and -1 are the two labels          // Triangle edges: 0-1, 1-2, 2-0         color[0] = 1;          // Edge 0-1 forces 1 to be opposite of 0         color[1] = -color[0];          // Edge 1-2 forces 2 to be opposite of 1         color[2] = -color[1];          // Closing edge 2-0 requires 0 to be opposite of 2         boolean closingEdgeOk = (color[0] == -color[2]);          System.out.println(&quot;Colors &quot; + Arrays.toString(color));         System.out.println(&quot;Closing edge 2-0 valid &quot; + closingEdgeOk);     } }" title="import java.util.Arrays;  public class OddCycleContradictionDemo {      public static void main(String[] args) {         int n = 3;         int[] color = new int[n]; // 0 unassigned, 1 and -1 are the two labels          // Triangle edges: 0-1, 1-2, 2-0         color[0] = 1;          // Edge 0-1 forces 1 to be opposite of 0         color[1] = -color[0];          // Edge 1-2 forces 2 to be opposite of 1         color[2] = -color[1];          // Closing edge 2-0 requires 0 to be opposite of 2         boolean closingEdgeOk = (color[0] == -color[2]);          System.out.println(&quot;Colors &quot; + Arrays.toString(color));         System.out.println(&quot;Closing edge 2-0 valid &quot; + closingEdgeOk);     } }" srcset="https://substackcdn.com/image/fetch/$s_!qx4U!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png 424w, https://substackcdn.com/image/fetch/$s_!qx4U!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png 848w, https://substackcdn.com/image/fetch/$s_!qx4U!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png 1272w, https://substackcdn.com/image/fetch/$s_!qx4U!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F22585f80-50fa-4d99-bdf0-bdc2a316119b_1747x841.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The last check prints <code>false</code>, because the alternating assignments around a three-edge cycle land on the same label at both endpoints of the closing edge, while the edge requires opposite labels.</p><p>Disconnected graphs follow the same rule. One component can be fully consistent while a different component contains an odd cycle. Bipartite only holds when every component can be split into two sets without contradiction, so a single odd cycle anywhere in the graph is enough to make the full graph fail.</p><h3>BFS Mechanics for Two Color Labeling</h3><p>Layer-based traversal works nicely for bipartite checks because every edge crossing flips the label, and BFS naturally moves outward in waves from a starting vertex. That steady outward expansion keeps the two-color rule consistent within the connected component, and it also places every edge in front of you at the moment it matters, right when you can confirm that the two endpoints still satisfy the opposite-label requirement.</p><h4>Color Storage and Queue Behavior</h4><p>Storing labels is the entire state of the check. One <code>int[]</code> works well, with <code>0</code> meaning unassigned and <code>1</code> and <code>-1</code> representing the two labels. That choice keeps the state compact, and the opposite label reads naturally as a sign flip, so assigning a neighbor becomes a direct expression of the rule rather than a separate mapping step. Queue choice affects how readable the BFS loop feels. <code>ArrayDeque&lt;Integer&gt;</code> fits well because it supports efficient insert at the back and removal from the front, and it avoids the per-node overhead that comes with a linked structure. As vertices receive labels, they go into the queue, and when a vertex comes out, its neighbor list gets scanned to push the labeling outward.</p><p>The flow is consistent within a component. Pick a start vertex, assign it <code>1</code>, push it into the queue, then repeat this loop until the queue is empty: pop <code>u</code>, scan each neighbor <code>v</code>, and if <code>v</code> has no label, assign <code>-color[u]</code> and push <code>v</code>. That is the propagation step that spreads labels through the component while preserving the two-color rule.</p><p>This helper colors a single connected component:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!oF5G!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!oF5G!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png 424w, https://substackcdn.com/image/fetch/$s_!oF5G!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png 848w, https://substackcdn.com/image/fetch/$s_!oF5G!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png 1272w, https://substackcdn.com/image/fetch/$s_!oF5G!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!oF5G!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png" width="1200" height="558.7912087912088" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:678,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:135921,&quot;alt&quot;:&quot;import java.util.ArrayDeque; import java.util.Deque; import java.util.List;  public final class BipartiteComponentBfs {      // Returns false if a contradiction is found in this component.     // color is shared across components, so the caller can reuse it.     public static boolean colorComponent(int start, List<List<Integer>> adj, int[] color) {         Deque<Integer> q = new ArrayDeque<>();         color[start] = 1;         q.addLast(start);          while (!q.isEmpty()) {             int u = q.removeFirst();             int cu = color[u];              for (int v : adj.get(u)) {                 if (color[v] == 0) {                     color[v] = -cu;                     q.addLast(v);                 } else if (color[v] == cu) {                     return false;                 }             }         }          return true;     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188640722?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import java.util.ArrayDeque; import java.util.Deque; import java.util.List;  public final class BipartiteComponentBfs {      // Returns false if a contradiction is found in this component.     // color is shared across components, so the caller can reuse it.     public static boolean colorComponent(int start, List<List<Integer>> adj, int[] color) {         Deque<Integer> q = new ArrayDeque<>();         color[start] = 1;         q.addLast(start);          while (!q.isEmpty()) {             int u = q.removeFirst();             int cu = color[u];              for (int v : adj.get(u)) {                 if (color[v] == 0) {                     color[v] = -cu;                     q.addLast(v);                 } else if (color[v] == cu) {                     return false;                 }             }         }          return true;     } }" title="import java.util.ArrayDeque; import java.util.Deque; import java.util.List;  public final class BipartiteComponentBfs {      // Returns false if a contradiction is found in this component.     // color is shared across components, so the caller can reuse it.     public static boolean colorComponent(int start, List<List<Integer>> adj, int[] color) {         Deque<Integer> q = new ArrayDeque<>();         color[start] = 1;         q.addLast(start);          while (!q.isEmpty()) {             int u = q.removeFirst();             int cu = color[u];              for (int v : adj.get(u)) {                 if (color[v] == 0) {                     color[v] = -cu;                     q.addLast(v);                 } else if (color[v] == cu) {                     return false;                 }             }         }          return true;     } }" srcset="https://substackcdn.com/image/fetch/$s_!oF5G!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png 424w, https://substackcdn.com/image/fetch/$s_!oF5G!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png 848w, https://substackcdn.com/image/fetch/$s_!oF5G!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png 1272w, https://substackcdn.com/image/fetch/$s_!oF5G!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fab80f224-9a1c-4624-be96-07a8b6d989f1_1805x841.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>That sign flip <code>color[v] = -cu</code> is doing real work. It is both the assignment and the guarantee that every edge you traverse tries to keep endpoints opposite. Some code stores labels as booleans, but booleans still need an extra state for unassigned, and the opposite assignment tends to read less directly than a sign flip.</p><h4>Conflict Detection During Traversal</h4><p>Contradictions are found during edge scans, right when both endpoints already have labels. When <code>u</code> already has a label and neighbor <code>v</code> has a label too, the edge requires them to be opposites. If they match, the component is not bipartite, and continuing the traversal cannot repair that edge without breaking earlier edges that already matched.</p><p>Placing the check inside the neighbor loop keeps the logic local. The moment a bad edge is seen, the method returns <code>false</code>, and the caller can stop immediately instead of carrying inconsistent state forward.</p><p>Self-loops naturally fail with the same check. If a vertex lists itself as a neighbor, the labels match immediately. Parallel edges do not change the answer, because repeated edges repeat the same constraint, so they either keep agreeing or keep conflicting.</p><p>This helper keeps the rule readable without hiding it:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!-NsL!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!-NsL!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png 424w, https://substackcdn.com/image/fetch/$s_!-NsL!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png 848w, https://substackcdn.com/image/fetch/$s_!-NsL!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png 1272w, https://substackcdn.com/image/fetch/$s_!-NsL!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!-NsL!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png" width="818" height="138.76785714285714" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/64bd6954-2428-4933-828b-3892ab02486e_1730x294.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:247,&quot;width&quot;:1456,&quot;resizeWidth&quot;:818,&quot;bytes&quot;:63207,&quot;alt&quot;:&quot;public final class ConflictRule {      // Returns true when the edge u-v contradicts two-color labeling.     public static boolean hasConflict(int[] color, int u, int v) {         return color[v] != 0 &amp;&amp; color[v] == color[u];     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188640722?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="public final class ConflictRule {      // Returns true when the edge u-v contradicts two-color labeling.     public static boolean hasConflict(int[] color, int u, int v) {         return color[v] != 0 &amp;&amp; color[v] == color[u];     } }" title="public final class ConflictRule {      // Returns true when the edge u-v contradicts two-color labeling.     public static boolean hasConflict(int[] color, int u, int v) {         return color[v] != 0 &amp;&amp; color[v] == color[u];     } }" srcset="https://substackcdn.com/image/fetch/$s_!-NsL!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png 424w, https://substackcdn.com/image/fetch/$s_!-NsL!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png 848w, https://substackcdn.com/image/fetch/$s_!-NsL!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png 1272w, https://substackcdn.com/image/fetch/$s_!-NsL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64bd6954-2428-4933-828b-3892ab02486e_1730x294.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>That helper assumes <code>color[u]</code> is already assigned, which matches the BFS loop where only labeled vertices are removed from the queue. Unassigned neighbors remain unknown at that moment, so a contradiction cannot be declared yet.</p><p>A short check shows how a self-loop triggers the rule without special casing:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!rWcz!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!rWcz!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png 424w, https://substackcdn.com/image/fetch/$s_!rWcz!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png 848w, https://substackcdn.com/image/fetch/$s_!rWcz!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png 1272w, https://substackcdn.com/image/fetch/$s_!rWcz!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!rWcz!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png" width="820" height="238.7912087912088" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:424,&quot;width&quot;:1456,&quot;resizeWidth&quot;:820,&quot;bytes&quot;:80553,&quot;alt&quot;:&quot;public class SelfLoopConflictDemo {      public static void main(String[] args) {         int[] color = new int[1];         color[0] = 1;          int u = 0;         int v = 0; // self-loop          System.out.println(ConflictRule.hasConflict(color, u, v)); // true     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188640722?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="public class SelfLoopConflictDemo {      public static void main(String[] args) {         int[] color = new int[1];         color[0] = 1;          int u = 0;         int v = 0; // self-loop          System.out.println(ConflictRule.hasConflict(color, u, v)); // true     } }" title="public class SelfLoopConflictDemo {      public static void main(String[] args) {         int[] color = new int[1];         color[0] = 1;          int u = 0;         int v = 0; // self-loop          System.out.println(ConflictRule.hasConflict(color, u, v)); // true     } }" srcset="https://substackcdn.com/image/fetch/$s_!rWcz!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png 424w, https://substackcdn.com/image/fetch/$s_!rWcz!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png 848w, https://substackcdn.com/image/fetch/$s_!rWcz!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png 1272w, https://substackcdn.com/image/fetch/$s_!rWcz!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb717ba88-a141-44f8-ae5f-2c6b98c4074e_1736x505.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>Traversal is not introducing conflicts. The graph already contains constraints, and scanning edges reveals contradictions when an edge forces two adjacent vertices to share the same label.</p><h4>Disconnected Graphs and Component Restarts</h4><p>Graphs can have multiple connected components, and a single BFS run only covers the component that contains its start vertex. Any component that remains unvisited would never get labels, and a contradiction sitting in that untouched component would never be discovered. Looping through all vertices fixes that. Each time an unassigned vertex is found, it becomes the seed for a new BFS run, receives an initial label, and the traversal labels and checks that component. If any component reports a contradiction, the whole graph fails the bipartite check.</p><p>The restart loop here is short, but it is what makes the check cover every connected component in the graph:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!r1j-!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!r1j-!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png 424w, https://substackcdn.com/image/fetch/$s_!r1j-!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png 848w, https://substackcdn.com/image/fetch/$s_!r1j-!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png 1272w, https://substackcdn.com/image/fetch/$s_!r1j-!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!r1j-!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png" width="966" height="404.0480769230769" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:609,&quot;width&quot;:1456,&quot;resizeWidth&quot;:966,&quot;bytes&quot;:100317,&quot;alt&quot;:&quot;import java.util.List;  public final class BipartiteWholeGraph {      public static boolean isBipartite(int n, List<List<Integer>> adj) {         int[] color = new int[n];          for (int start = 0; start < n; start++) {             if (color[start] != 0) {                 continue;             }              boolean ok = BipartiteComponentBfs.colorComponent(start, adj, color);             if (!ok) {                 return false;             }         }          return true;     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188640722?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import java.util.List;  public final class BipartiteWholeGraph {      public static boolean isBipartite(int n, List<List<Integer>> adj) {         int[] color = new int[n];          for (int start = 0; start < n; start++) {             if (color[start] != 0) {                 continue;             }              boolean ok = BipartiteComponentBfs.colorComponent(start, adj, color);             if (!ok) {                 return false;             }         }          return true;     } }" title="import java.util.List;  public final class BipartiteWholeGraph {      public static boolean isBipartite(int n, List<List<Integer>> adj) {         int[] color = new int[n];          for (int start = 0; start < n; start++) {             if (color[start] != 0) {                 continue;             }              boolean ok = BipartiteComponentBfs.colorComponent(start, adj, color);             if (!ok) {                 return false;             }         }          return true;     } }" srcset="https://substackcdn.com/image/fetch/$s_!r1j-!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png 424w, https://substackcdn.com/image/fetch/$s_!r1j-!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png 848w, https://substackcdn.com/image/fetch/$s_!r1j-!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png 1272w, https://substackcdn.com/image/fetch/$s_!r1j-!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8993e719-e906-4b5a-aa5b-0236cff3feda_1759x736.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Starting each new component with label <code>1</code> is always fine because components have no edges between them, so the label choice in one component cannot constrain another component. Isolated vertices behave well too, because an isolated vertex has no neighbors, so it gets labeled and the queue drains immediately.</p><h4>Data Structures to Use</h4><p>Representation choice drives neighbor scanning cost, so the storage should match what BFS does most. The traversal repeatedly takes a vertex and iterates its neighbors, so fast neighbor iteration is what matters most. Adjacency lists fit that workflow well. In Java, <code>List&lt;List&lt;Integer&gt;&gt;</code> is a common choice, where the outer list index is the vertex id and the inner list contains neighbor ids. Sparse graphs benefit the most, because memory tracks actual edges rather than all possible pairs, and the BFS work stays close to <code>V + E</code> because each adjacency entry is visited once.</p><p>Adjacency matrices like <code>boolean[][]</code> can be good for very small graphs because adjacency checks are direct. Memory cost grows as <code>n * n</code>, and neighbor scanning tends to scan a full row, which can turn the traversal into a dense operation even when few edges exist. Queue choice also matters, <code>ArrayDeque</code> supports <code>addLast</code> and <code>removeFirst</code> efficiently and keeps memory overhead low. <code>LinkedList</code> can act as a queue too, but it carries extra node objects and pointer chasing that provide no benefit for BFS.</p><p>Building the undirected adjacency list up front keeps the representation consistent and puts all edge insertion logic in one place:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!m7Tc!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!m7Tc!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png 424w, https://substackcdn.com/image/fetch/$s_!m7Tc!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png 848w, https://substackcdn.com/image/fetch/$s_!m7Tc!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png 1272w, https://substackcdn.com/image/fetch/$s_!m7Tc!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!m7Tc!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png" width="958" height="494.7912087912088" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:752,&quot;width&quot;:1456,&quot;resizeWidth&quot;:958,&quot;bytes&quot;:144622,&quot;alt&quot;:&quot;import java.util.ArrayList; import java.util.List;  public final class GraphBuilders {      public static List<List<Integer>> buildUndirectedAdjacencyList(int n, int[][] edges) {         List<List<Integer>> adj = new ArrayList<>(n);         for (int i = 0; i < n; i++) {             adj.add(new ArrayList<>());         }          for (int[] e : edges) {             int a = e[0];             int b = e[1];              if (a < 0 || a >= n || b < 0 || b >= n) {                 throw new IllegalArgumentException(\&quot;vertex out of range\&quot;);             }              adj.get(a).add(b);             adj.get(b).add(a);         }          return adj;     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/188640722?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import java.util.ArrayList; import java.util.List;  public final class GraphBuilders {      public static List<List<Integer>> buildUndirectedAdjacencyList(int n, int[][] edges) {         List<List<Integer>> adj = new ArrayList<>(n);         for (int i = 0; i < n; i++) {             adj.add(new ArrayList<>());         }          for (int[] e : edges) {             int a = e[0];             int b = e[1];              if (a < 0 || a >= n || b < 0 || b >= n) {                 throw new IllegalArgumentException(&quot;vertex out of range&quot;);             }              adj.get(a).add(b);             adj.get(b).add(a);         }          return adj;     } }" title="import java.util.ArrayList; import java.util.List;  public final class GraphBuilders {      public static List<List<Integer>> buildUndirectedAdjacencyList(int n, int[][] edges) {         List<List<Integer>> adj = new ArrayList<>(n);         for (int i = 0; i < n; i++) {             adj.add(new ArrayList<>());         }          for (int[] e : edges) {             int a = e[0];             int b = e[1];              if (a < 0 || a >= n || b < 0 || b >= n) {                 throw new IllegalArgumentException(&quot;vertex out of range&quot;);             }              adj.get(a).add(b);             adj.get(b).add(a);         }          return adj;     } }" srcset="https://substackcdn.com/image/fetch/$s_!m7Tc!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png 424w, https://substackcdn.com/image/fetch/$s_!m7Tc!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png 848w, https://substackcdn.com/image/fetch/$s_!m7Tc!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png 1272w, https://substackcdn.com/image/fetch/$s_!m7Tc!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F113633ce-6a09-46ca-b7e4-9c4141d77877_1759x909.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Most of the work during traversal is iterating neighbor lists, so <code>ArrayList</code> is a good default for those inner lists. Duplicate edges do not change correctness for bipartite checks, so storing neighbors in a list is usually enough without paying hashing overhead from sets.</p><h4>Time Complexity and Space Complexity</h4><p>Running the bipartite check with BFS takes <code>O(V + E)</code> time, where <code>V</code> is the number of vertices and <code>E</code> is the number of edges. Each vertex gets labeled at most one time, and each adjacency entry gets scanned during neighbor iteration. In an undirected adjacency list, each edge appears twice, but that still stays linear in <code>V + E</code>, so the total work grows with the graph and its connections.</p><p>Extra working memory stays at <code>O(V)</code>. The <code>color</code> array holds one integer per vertex, and the queue can hold up to <code>V</code> vertices in the worst case. If you include the graph storage itself, the adjacency list takes <code>O(V + E)</code> space, but that is the input representation rather than extra memory created by the check.</p><h3>Conclusion</h3><p>Two-color bipartite checks come down to one rule repeated consistently across edges. Every time you move from a vertex to a neighbor, the label flips, and BFS makes that flip easy to carry forward because vertices leave the queue in a steady order and each neighbor list gets processed exactly when its vertex is popped. Conflicts show up the moment an edge connects two vertices that already share a label, which includes self-loops and odd-cycle closures. Disconnected graphs stay covered by restarting the traversal from any vertex that still has label <code>0</code>, while an adjacency list, <code>int[]</code> for labels, and <code>ArrayDeque</code> for the queue keep the work focused on scanning actual edges rather than checking every possible pair.</p><ol><li><p><em><a href="https://docs.oracle.com/en/java/javase/">Java Platform SE Documentation</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/doc-files/coll-overview.html">Java Collections Framework Overview</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayDeque.html">ArrayDeque JavaDoc</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Deque.html">Deque JavaDoc</a></em></p></li><li><p><em><a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html">List JavaDoc</a></em></p></li><li><p><em><a href="https://mathworld.wolfram.com/BipartiteGraph.html">Graph Theory Bipartite Graph (Great for visuals!)</a></em></p></li><li><p><em><a href="https://cp-algorithms.com/graph/bipartite-check.html">Breadth-First Search</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[gRPC Gateway From Spring Boot to Legacy SOAP Services]]></title><description><![CDATA[Legacy SOAP services still handle a lot of important business work, but newer client builds often prefer gRPC for quicker calls, tight contracts, and better cross language support, so a gateway service ends up as the middle layer that keeps the legacy side working while giving modern clients a nicer interface.]]></description><link>https://alexanderobregon.substack.com/p/grpc-gateway-from-spring-boot-to</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/grpc-gateway-from-spring-boot-to</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Fri, 27 Feb 2026 21:46:18 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/2a8e5ae8-fffc-4d3c-9918-adc1946eeb6c_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ZN2z!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ZN2z!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!ZN2z!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!ZN2z!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!ZN2z!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ZN2z!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ZN2z!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!ZN2z!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!ZN2z!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!ZN2z!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20f320c1-4753-42f2-8644-4d2cd14d4f4e_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://spring.io/projects/spring-boot">Image Source</a></figcaption></figure></div><p>Legacy SOAP services still handle a lot of important business work, but newer client builds often prefer gRPC for quicker calls, tight contracts, and better cross language support, so a gateway service ends up as the middle layer that keeps the legacy side working while giving modern clients a nicer interface. This article is beginner friendly, so it starts with a quick overview of the gateway flow and does not assume prior experience with gRPC or SOAP.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>How the Gateway Works at Runtime</h3><p>Think of the gateway as a translator between two contracts that do not share the same language. gRPC clients send protobuf messages over HTTP 2, the gateway receives those messages as typed Java objects, then it builds a SOAP XML request that matches the WSDL contract, sends it over HTTP, and maps the SOAP response back into a protobuf response before returning to the client.</p><h4>Request Flow From Client to SOAP</h4><p>Client traffic arrives as a gRPC call with a method name and a protobuf payload. The gRPC server decodes that payload into the generated request type and calls your service method. From there, the gateway has to do two jobs without letting either side leak into the other. First, it pulls inputs from the protobuf request and validates them in a way that fits the public API. Second, it translates those inputs into what the SOAP operation expects, including namespaces, wrapper elements, and the operation name that the legacy endpoint recognizes.</p><p>SOAP operations typically have a request wrapper type, and sometimes a response wrapper type too, plus XML schema details like optional elements. gRPC does not care about any of that. The gateway bridges the gap by building a SOAP request object, sending it, then extracting the SOAP response into an internal result type that maps cleanly into protobuf.</p><p>Keeping SOAP generated classes away from the gRPC surface helps a lot. One clean way to do that is to keep all SOAP object creation and extraction inside the SOAP client, then use small helper methods inside that client for the repetitive mapping work.</p><p>That helper can live next to the SOAP client and be called from inside <code>fetchAccountSummary</code>, so the gRPC layer never needs to touch SOAP request or response types:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;fda517d6-4d15-4431-a0a8-937a674a2efe&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public final class AccountSoapMapper {

    private AccountSoapMapper() {}

    public static com.legacy.account.GetAccountSummaryRequest toSoapRequest(String accountId) {
        var req = new com.legacy.account.GetAccountSummaryRequest();
        req.setAccountId(accountId);
        return req;
    }

    public static GatewayTypes.AccountSummary fromSoapResponse(com.legacy.account.GetAccountSummaryResponse res) {
        return new GatewayTypes.AccountSummary(
                res.getAccountId(),
                res.getStatus(),
                MoneyMapper.toMinorUnits(res.getCurrentBalance()),
                res.getCurrency()
        );
    }
}</code></pre></div><p>With that separation, the service method stays focused on orchestration. It receives a protobuf request, calls the SOAP client, maps the internal result into protobuf, and returns.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;2c667507-598d-4e77-a68d-4516810a669d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">@Override
public void getAccountSummary(
        GetAccountSummaryRequest request,
        StreamObserver&lt;GetAccountSummaryResponse&gt; responseObserver
) {
    String accountId = request.getAccountId();

    if (accountId == null || accountId.isBlank()) {
        responseObserver.onError(
                Status.INVALID_ARGUMENT.withDescription("account_id is required").asRuntimeException()
        );
        return;
    }

    try {
        GatewayTypes.AccountSummary summary = soapClient.fetchAccountSummary(accountId);

        var reply = GetAccountSummaryResponse.newBuilder()
                .setAccountId(summary.accountId())
                .setStatus(summary.status())
                .setCurrentBalance(
                        Money.newBuilder()
                                .setAmountMinor(summary.balanceMinor())
                                .setCurrency(summary.currency())
                                .build()
                )
                .build();

        responseObserver.onNext(reply);
        responseObserver.onCompleted();
    } catch (AccountSoapClient.NotFound e) {
        responseObserver.onError(
                Status.NOT_FOUND.withDescription("account not found").asRuntimeException()
        );
    } catch (AccountSoapClient.LegacyUnavailable e) {
        responseObserver.onError(
                Status.UNAVAILABLE.withDescription("legacy service unavailable").asRuntimeException()
        );
    } catch (Exception e) {
        responseObserver.onError(
                Status.INTERNAL.withDescription("gateway error").asRuntimeException()
        );
    }
}</code></pre></div><p>Problem cases deserve the same attention as the successful call. gRPC completes a call when the server finishes the response observer. SOAP calls run as outbound HTTP requests that can time out, throw transport exceptions, or return SOAP faults. The gateway translates those failure modes into gRPC status codes that clients already handle well.</p><p>Later in the mapping section, the same idea is handled by a small mapper so each service method can call one helper instead of repeating <code>Status</code> handling in every catch block.</p><h4>Protobuf as the Public Contract</h4><p>Protobuf definitions act as the contract that modern clients build against, so those messages should reflect business meaning instead of XML quirks. SOAP schemas frequently have wrapper elements, optional tags, and naming choices that exist because of XML schema constraints. Protobuf does not need to copy that structure to stay correct. The gateway can keep protobuf messages stable and client friendly, then handle the SOAP specific mapping internally.</p><p>This pays off in two places, client developers see a consistent gRPC API even if the SOAP schema has awkward corners. The gateway also becomes the place where you control change, because SOAP contracts may stay frozen for years while the gRPC contract can add fields in a forward compatible way.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;cbf577cd-5dce-4c67-a30a-aad58b42400c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public final class AccountTranslator {

    private AccountTranslator() {}

    public static GetAccountSummaryResponse toGrpcResponse(GatewayTypes.AccountSummary summary) {
        return GetAccountSummaryResponse.newBuilder()
                .setAccountId(summary.accountId())
                .setStatus(summary.status())
                .setCurrentBalance(
                        Money.newBuilder()
                                .setAmountMinor(summary.balanceMinor())
                                .setCurrency(summary.currency())
                                .build()
                )
                .build();
    }
}</code></pre></div><p>That translator looks intentionally plain. It makes it harder for SOAP details to creep into the protobuf contract. Extra fields in the SOAP response can stay internal until the gRPC API is ready to expose them. When the gRPC API needs a new field later, protobuf can add it without breaking older clients, and the gateway can populate it from SOAP if the data exists.</p><p>Type normalization belongs here too. SOAP commonly returns decimals for money and strings for dates or codes. Protobuf tends to work better with normalized types like integers for minor currency units or a structured timestamp type. The gateway can apply one consistent conversion rule at the boundary so clients do not repeat the same conversion work in different languages.</p><h4>Deadlines and Latency Boundaries</h4><p>gRPC supports deadlines, which give the server a client supplied time budget for the call. That budget matters because the gateway spends part of the request on translation and transport, then spends the rest waiting on the SOAP endpoint. SOAP does not carry a native deadline concept in the same way, so the gateway needs to connect the gRPC deadline to SOAP client timeouts and to its own behavior.</p><p>SOAP calls through <code>WebServiceTemplate</code> block the thread until the HTTP request finishes or times out. In Java gRPC, your service method normally runs on a worker thread, not on Netty event loop threads, so the main rule is to avoid running blocking SOAP work on an event loop if you ever configure a direct executor or otherwise move application work onto event loop threads.</p><p>Start by reading the deadline from the gRPC context. Java gRPC exposes this via <code>io.grpc.Context</code>. When a deadline exists, treat it as the upper bound for the call. The gateway can read the remaining time at the start and pass that value down so the SOAP client can set timeouts that fit inside the budget.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;4bda08ab-1852-47bf-8829-ccf214862055&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">import io.grpc.Context;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

public final class GrpcDeadline {

    private GrpcDeadline() {}

    public static Duration remainingOrNull() {
        var deadline = Context.current().getDeadline();
        if (deadline == null) return null;

        long nanos = deadline.timeRemaining(TimeUnit.NANOSECONDS);
        if (nanos &lt;= 0) return Duration.ZERO;

        return Duration.ofNanos(nanos);
    }
}</code></pre></div><p>After the remaining time is available, choose SOAP timeouts that align with it. Leaving a small buffer helps because mapping work and response write back still need time after the SOAP call returns. Without that buffer, the SOAP call can finish successfully while the gRPC deadline expires right before the response goes out, which leads to confusing client behavior.</p><p>Latency boundaries matter for another reason. The gateway adds overhead compared to direct client to SOAP calls. Some of that overhead is unavoidable, like protobuf decode and encode, XML marshalling and parsing, and network hops. Keeping that overhead predictable helps a lot. Reuse SOAP client objects, reuse JAXB contexts and marshallers, and avoid building heavy XML infrastructure per request. When the SOAP endpoint slows down, the gateway should fail fast in a consistent way, translating timeouts into <code>DEADLINE_EXCEEDED</code> or <code>UNAVAILABLE</code> rather than tying up threads until the process becomes unhealthy.</p><h3>Building the Spring Boot Service</h3><p>Spring Boot fits this gateway well because it gives you one process to host the gRPC server, the SOAP client, and the translation code, while still letting you separate those concerns in your own packages. Spring Boot 3 already moved the stack to the <code>jakarta.*</code> namespace through Spring Framework 6 and a Jakarta EE 9 baseline, and Spring Boot 4 carries that forward on Spring Framework 7 with a Jakarta EE 11 baseline, so the SOAP stack and JAXB marshalling should come from libraries in the <code>jakarta.*</code> generation such as Spring Web Services 4.x.</p><h4>Project Setup With Modern Dependencies</h4><p>Dependency choices fall into two buckets. gRPC in Java typically runs on Netty, so the service process hosts a Netty based gRPC server that accepts HTTP 2 calls and routes them to your generated service implementation. SOAP calls go out over HTTP, and Spring Web Services pairs well with <code>WebServiceTemplate</code> plus JAXB marshalling so request and response objects can move between Java types and XML without hand built XML strings.</p><p>Spring Boot 4.0.x expects a current toolchain, so run the gateway on Java 17 or newer and use a supported Gradle version like 8.14 or later. Getting those basics lined up early prevents confusing build errors that have nothing to do with gRPC or SOAP.</p><p>Build files vary by team, so focus on the moving parts you bring in. gRPC needs the protobuf compiler step plus the runtime libraries, while SOAP needs Spring Web Services and an XML marshaller. Lets see it in the build file so the moving parts are visible:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;59055a6c-adc6-41ca-898c-1603482a2ee1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">plugins {
  id "java"
  id "org.springframework.boot" version "4.0.3"
  id "io.spring.dependency-management" version "1.1.7"
  id "com.google.protobuf" version "0.9.6"
}

repositories {
  mavenCentral()
}

dependencies {
  implementation "org.springframework.boot:spring-boot-starter"

  implementation platform("io.grpc:grpc-bom:1.79.0")
  implementation "io.grpc:grpc-netty-shaded"
  implementation "io.grpc:grpc-protobuf"
  implementation "io.grpc:grpc-stub"

  implementation "org.springframework.ws:spring-ws-core"
  implementation "org.springframework.boot:spring-boot-starter-web-services"
  implementation "org.springframework:spring-oxm"

  implementation "org.apache.httpcomponents.client5:httpclient5:5.6"
}

protobuf {
  protoc { artifact = "com.google.protobuf:protoc:4.34.0" }
  plugins {
    grpc { artifact = "io.grpc:protoc-gen-grpc-java:1.79.0" }
  }
  generateProtoTasks {
    all().each { t -&gt;
      t.plugins { grpc {} }
    }
  }
}</code></pre></div><p>Gradle can manage BOMs with <code>platform()</code>, so <code>io.spring.dependency-management</code> is optional here.</p><p>Operational settings belong in configuration rather than scattered in constructors. gRPC has its own port and TLS story, while SOAP has endpoint URIs and timeout values that vary between environments. Centralizing those values helps prevent accidental cross environment drift:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;yaml&quot;,&quot;nodeId&quot;:&quot;20377711-9efd-4893-8d17-388eae27f516&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-yaml">gateway:
  grpc:
    port: 9090
  soap:
    accountServiceUri: https://legacy.example.com/AccountService
    connectTimeoutMs: 2000
    responseTimeoutMs: 3000</code></pre></div><h4>Proto and gRPC Service Implementation</h4><p>The proto file defines the public contract. Java code is generated from it, then your service class extends the generated base type and implements the methods that clients call. That service class should stay small. Let it validate the request at the API boundary, call a SOAP client abstraction, map returned data into protobuf, then complete the gRPC response.</p><p>Proto generation creates two sets of Java types. Message types come from the <code>message</code> definitions, while a generated service base class comes from the <code>service</code> block, usually ending in <code>Grpc</code> with an inner <code>ImplBase</code> class that you extend. Keeping <code>option java_package</code> and <code>option java_multiple_files</code> set in the proto keeps generated class names and packages predictable, which makes imports and refactors less painful.</p><p>This is what that looks like in a small proto excerpt:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c02b7a4f-1591-416f-89db-c5f840660f0a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">syntax = "proto3";

package gateway.v1;

option java_package = "com.example.gateway.v1";
option java_multiple_files = true;

service AccountGateway {
  rpc GetAccountSummary(GetAccountSummaryRequest) returns (GetAccountSummaryResponse);
}</code></pre></div><p>Hosting the gRPC server inside the Spring Boot process keeps deployment smooth, but the server lifecycle still matters. Startup and shutdown should be clean so ports are not left open and in flight requests are not dropped abruptly.</p><p>Spring collects your gRPC service classes through normal component scanning. Any bean that implements <code>BindableService</code> can be injected into the lifecycle wrapper, which is why <code>List&lt;BindableService&gt;</code> works without extra registration code. Each service gets added to the <code>NettyServerBuilder</code>, and the server ends up listening on its own port, separate from any Spring MVC or WebFlux HTTP server you may run in the same process.</p><p>This server wrapper ties into Spring lifecycle hooks:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;37c66cf2-8fd0-4093-9db2-77fb4ec651c0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.gateway.grpc;

import io.grpc.BindableService;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.List;

@Component
public class GrpcServerLifecycle implements InitializingBean, DisposableBean {

    private final int port;
    private final List&lt;BindableService&gt; services;
    private Server server;

    public GrpcServerLifecycle(
            @Value("${gateway.grpc.port}") int port,
            List&lt;BindableService&gt; services
    ) {
        this.port = port;
        this.services = services;
    }

    @Override
    public void afterPropertiesSet() throws IOException {
        var builder = NettyServerBuilder.forPort(port);

        for (var service : services) {
            builder.addService(service);
        }

        server = builder.build();
        server.start();
    }

    @Override
    public void destroy() {
        if (server != null) {
            server.shutdown();
        }
    }
}</code></pre></div><p>Netty hosts the gRPC server, Spring handles startup and shutdown, and you can layer in TLS, reflection, and health checks later without changing how the server is created.</p><h4>SOAP Client With Spring Web Services</h4><p>Generally, SOAP integration goes smoother when the gRPC layer never touches SOAP generated types directly. Put SOAP work behind a client class that accepts plain inputs and returns a small internal value object. That client owns <code>WebServiceTemplate</code>, marshalling, headers, timeouts, and fault handling.</p><p>Begin with a JAXB marshaller configuration that points at the generated classes package. This example assumes your WSDL code generation step outputs JAXB annotated types into a stable package:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;ccdf62e3-f2dd-40ca-ae56-d418b0a18021&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.gateway.soap;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.oxm.jaxb.Jaxb2Marshaller;

@Configuration
public class SoapMarshallingConfig {

    @Bean
    public Jaxb2Marshaller accountServiceMarshaller() {
        var marshaller = new Jaxb2Marshaller();
        marshaller.setContextPath("com.legacy.account");
        return marshaller;
    }
}</code></pre></div><p><code>setContextPath</code> should match the package that contains the JAXB annotated classes generated from the WSDL. When that package is wrong, <code>WebServiceTemplate</code> can fail at runtime because the marshaller cannot find the types that match the XML it needs to write.</p><p>Timeouts belong on the HTTP client that backs <code>WebServiceTemplate</code>. With Spring Web Services 4.0.5 and newer, use <code>HttpComponents5MessageSender</code> for Apache HttpClient 5 configuration:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;5291aace-dfbc-4a8e-82cf-2df8b98208a3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.gateway.soap;

import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.util.Timeout;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.ws.transport.http.HttpComponents5MessageSender;

@Configuration
public class SoapHttpConfig {

    @Bean
    public HttpComponents5MessageSender soapMessageSender(
            @Value("${gateway.soap.connectTimeoutMs}") long connectTimeoutMs,
            @Value("${gateway.soap.responseTimeoutMs}") long responseTimeoutMs
    ) {
        var config = RequestConfig.custom()
                .setConnectTimeout(Timeout.ofMilliseconds(connectTimeoutMs))
                .setResponseTimeout(Timeout.ofMilliseconds(responseTimeoutMs))
                .build();

        var client = HttpClients.custom()
                .setDefaultRequestConfig(config)
                .build();

        var sender = new HttpComponents5MessageSender(client);
        return sender;
    }
}</code></pre></div><p>With those pieces in place, the SOAP client can focus on turning inputs into a request object, calling the legacy endpoint, and converting the response into a compact internal result. SOAP actions, headers, and response unwrapping tend to be where the gritty details live, and Spring Web Services can return the response object directly or wrapped in a <code>JAXBElement</code> depending on how the WSDL types were generated, so handling both cases keeps the call site consistent:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;c4ba3a0e-e317-477c-b59b-fa370d2fe08e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.gateway.soap;

import jakarta.xml.bind.JAXBElement;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.oxm.jaxb.Jaxb2Marshaller;
import org.springframework.stereotype.Component;
import org.springframework.ws.client.WebServiceIOException;
import org.springframework.ws.client.core.WebServiceTemplate;
import org.springframework.ws.soap.SoapMessage;
import org.springframework.ws.soap.client.SoapFaultClientException;
import org.springframework.ws.transport.http.HttpComponents5MessageSender;

import java.util.UUID;

@Component
public class AccountSoapClient {

    public record AccountSummary(String accountId, String status, long balanceMinor, String currency) {}

    public static final class NotFound extends RuntimeException {}
    public static final class LegacyUnavailable extends RuntimeException {}

    private final WebServiceTemplate template;

    public AccountSoapClient(
            Jaxb2Marshaller accountServiceMarshaller,
            HttpComponents5MessageSender soapMessageSender,
            @Value("${gateway.soap.accountServiceUri}") String uri
    ) {
        template = new WebServiceTemplate(accountServiceMarshaller);
        template.setDefaultUri(uri);
        template.setMessageSender(soapMessageSender);
        template.setInterceptors(new org.springframework.ws.client.support.interceptor.ClientInterceptor[] {
                new CorrelationHeaderInterceptor()
        });
    }

    public AccountSummary fetchAccountSummary(String accountId) {
        try {
            var request = new com.legacy.account.GetAccountSummaryRequest();
            request.setAccountId(accountId);

            Object raw = template.marshalSendAndReceive(request, message -&gt; {
                if (message instanceof SoapMessage soap) {
                    soap.setSoapAction("GetAccountSummary");
                }
            });

            com.legacy.account.GetAccountSummaryResponse response = unwrap(raw);

            return new AccountSummary(
                    response.getAccountId(),
                    response.getStatus(),
                    MoneyMapper.toMinorUnits(response.getCurrentBalance()),
                    response.getCurrency()
            );
        } catch (SoapFaultClientException e) {
            if (faultLooksLikeNotFound(e)) {
                throw new NotFound();
            }
            throw new LegacyUnavailable();
        } catch (WebServiceIOException e) {
            throw new LegacyUnavailable();
        }
    }

    private static com.legacy.account.GetAccountSummaryResponse unwrap(Object raw) {
        if (raw instanceof JAXBElement&lt;?&gt; el &amp;&amp; el.getValue() instanceof com.legacy.account.GetAccountSummaryResponse cast) {
            return cast;
        }
        if (raw instanceof com.legacy.account.GetAccountSummaryResponse cast) {
            return cast;
        }
        throw new IllegalStateException("unexpected SOAP response type: " + raw.getClass().getName());
    }

    private static boolean faultLooksLikeNotFound(SoapFaultClientException e) {
        String reason = e.getFaultStringOrReason();
        return reason != null &amp;&amp; reason.toLowerCase().contains("not found");
    }

    static final class CorrelationHeaderInterceptor implements org.springframework.ws.client.support.interceptor.ClientInterceptor {
        @Override
        public boolean handleRequest(org.springframework.ws.context.MessageContext messageContext) {
            var request = messageContext.getRequest();
            if (request instanceof SoapMessage soap) {
                var header = soap.getSoapHeader();
                header.addHeaderElement(new javax.xml.namespace.QName("urn:gateway", "correlationId"))
                        .setText(UUID.randomUUID().toString());
            }
            return true;
        }

        @Override
        public boolean handleResponse(org.springframework.ws.context.MessageContext messageContext) {
            return true;
        }

        @Override
        public boolean handleFault(org.springframework.ws.context.MessageContext messageContext) {
            return true;
        }
    }
}</code></pre></div><h4>Mapping Rules, Errors, and Observability</h4><p>Mapping rules are where contracts stay stable. SOAP responses can carry values as decimals, strings, or nested wrapper objects, while protobuf messages tend to favor normalized types. Money is a good example. Keeping money as minor units in protobuf avoids floating point issues, then the gateway handles decimal conversion in one place. Dates follow the same idea. Decide on a representation for the gRPC API and convert at the gateway boundary instead of pushing that work into every client.</p><p>Error mapping needs equal attention. SOAP faults, transport failures, and timeouts should become gRPC status codes that client libraries already speak. Small mapping helpers keep that logic consistent across methods, and the service implementation stays readable:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;0960c191-f8b5-418c-9b98-b9a964d3ff5d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">package com.example.gateway.grpc;

import com.example.gateway.soap.AccountSoapClient;
import io.grpc.Status;

import java.util.concurrent.TimeoutException;

public final class GrpcErrorMapper {

    private GrpcErrorMapper() {}

    public static Status toStatus(Throwable t) {
        if (t instanceof AccountSoapClient.NotFound) {
            return Status.NOT_FOUND.withDescription("account not found");
        }
        if (t instanceof AccountSoapClient.LegacyUnavailable) {
            return Status.UNAVAILABLE.withDescription("legacy service unavailable");
        }
        if (t instanceof TimeoutException) {
            return Status.DEADLINE_EXCEEDED.withDescription("deadline exceeded");
        }
        return Status.INTERNAL.withDescription("gateway error");
    }
}</code></pre></div><p><code>Status</code> becomes a gRPC error only after it is thrown as an exception. In the service method, call <code>GrpcErrorMapper.toStatus(t).asRuntimeException()</code> so the framework sends the status code and description back to the client. That helper can live next to the mapper so service methods stay short:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;java&quot;,&quot;nodeId&quot;:&quot;bd039559-bd81-4d6a-b7dc-4e020d752724&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-java">public static RuntimeException toGrpcException(Throwable t) {
    return GrpcErrorMapper.toStatus(t).asRuntimeException();
}</code></pre></div><p>Observability belongs in the gateway because it touches both sides. A correlation ID should be attached to the outbound SOAP call when the legacy service can accept it, and that same value should be logged on the gRPC side so you can trace one client call through the gateway and into the SOAP dependency. Distributed tracing fits naturally too. Create a span around the outbound SOAP call, record latency and status, then propagate identifiers through headers where possible so traces link across boundaries.</p><h3>Conclusion</h3><p>gRPC to SOAP gateways work best when the boundaries stay tight. Protobuf stays as the public contract, the SOAP client owns XML and WSDL details, and the gateway handles translation in the middle so the two sides do not leak concerns into each other. After that, the mechanics stay pretty consistent across endpoints. Convert types in a single place, translate failures into <code>Status</code> in a single place, and treat deadlines and timeouts as a shared budget across the full request. When those parts stay in their lanes, service methods stay short, failure behavior stays predictable, and tracing a call from gRPC through the gateway into SOAP becomes a normal part of debugging.</p><ol><li><p><em><a href="https://grpc.io/docs/languages/java/">gRPC Java Documentation</a></em></p></li><li><p><em><a href="https://protobuf.dev/programming-guides/proto3/">Protocol Buffers Language Guide proto3</a></em></p></li><li><p><em><a href="https://protobuf.dev/reference/java/java-generated/">Protocol Buffers Java Generated Code Guide</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-boot/index.html">Spring Boot Reference Documentation</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-ws/site/reference/html/">Spring Web Services Reference Documentation</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-ws/docs/current/api/org/springframework/ws/client/core/WebServiceTemplate.html">Spring Web Services </a></em><code>WebServiceTemplate</code><em><a href="https://docs.spring.io/spring-ws/docs/current/api/org/springframework/ws/client/core/WebServiceTemplate.html"> API</a></em></p></li><li><p><em><a href="https://hc.apache.org/httpcomponents-client-5.0.x/index.html">Apache HttpClient 5 Documentation</a></em></p></li><li><p><em><a href="https://grpc.github.io/grpc-java/javadoc/io/grpc/Status.html">gRPC Status Codes and </a></em><code>io.grpc.Status</code></p></li><li><p><em><a href="https://grpc.github.io/grpc-java/javadoc/io/grpc/Context.html">gRPC Java </a></em><code>Context</code><em><a href="https://grpc.github.io/grpc-java/javadoc/io/grpc/Context.html"> and Deadlines</a></em></p></li><li><p><em><a href="https://opentelemetry.io/docs/concepts/signals/traces/">OpenTelemetry Tracing Docs</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!JL77!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!JL77!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!JL77!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!JL77!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!JL77!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!JL77!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!JL77!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!JL77!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!JL77!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!JL77!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F92d51a24-2b37-412f-853f-e3feb049ce7f_276x276.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://icons8.com/icon/90519/spring-boot">Spring Boot</a> icon by <a href="https://icons8.com/">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[Java Basics and Fundamentals Starter Guide]]></title><description><![CDATA[Learn Java basics with a guided reading order covering JVM terms, objects, strings, and beginner practice posts, then what to read next.]]></description><link>https://alexanderobregon.substack.com/p/java-basics-and-fundamentals-starter</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/java-basics-and-fundamentals-starter</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Wed, 25 Feb 2026 21:48:23 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!PYo8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!PYo8!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!PYo8!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!PYo8!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!PYo8!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!PYo8!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!PYo8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png" width="328" height="328" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e254861b-2839-4fd2-a677-536357f95df3_328x328.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:328,&quot;width&quot;:328,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:&quot;&quot;,&quot;title&quot;:&quot;&quot;,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" title="" srcset="https://substackcdn.com/image/fetch/$s_!PYo8!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png 424w, https://substackcdn.com/image/fetch/$s_!PYo8!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png 848w, https://substackcdn.com/image/fetch/$s_!PYo8!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png 1272w, https://substackcdn.com/image/fetch/$s_!PYo8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe254861b-2839-4fd2-a677-536357f95df3_328x328.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://www.oracle.com/java/java-affinity/logos/">Image Source</a></figcaption></figure></div><p>Starting Java can feel scattered because people talk about tools, syntax, and the JVM like they are the same thing. They connect, but they answer different beginner questions. When I teach Java through the posts in my J<a href="https://alexanderobregon.substack.com/i/185468970/java-basics-and-fundamentals">ava Basics and Fundamentals</a> section, I like to separate those ideas early so the rest of the learning feels less jumpy. This guide is my suggested starting route through my own articles, with a quick map of what matters first and a short set of reads to begin with in a order that makes sense.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>What To Learn First In Java</h3><p>Java has two layers you learn at the same time. One layer is the language you type, like variables, loops, methods, objects, and strings. The other layer is how Java runs, meaning compiled bytecode, the JVM, and the tools that build and run your code. Beginners get stuck when those layers blur. Things go smoother when you have a basic idea of both in your head, then you practice small problems until the syntax starts to feel natural.</p><h4>Tooling Basics To Get Out Of The Way Early</h4><p>Before jumping into drills, it helps to know what you are installing and what runs your code. JDK is the developer kit that includes the compiler and tools. JVM is the runtime that executes bytecode. JRE is the runtime bundle that people still mention, and learning how it relates to the JDK clears up confusion fast when different tutorials use different terms.</p><p>Start with:</p><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;48e37b0a-865f-4972-ba32-531baedde928&quot;,&quot;caption&quot;:&quot;Many beginners ask what separates the JVM, the JRE, and the JDK. The names sound alike, but each one has a distinct place in the Java world. Together they cover what runs code, what gives developers their tools, and what links the two sides.&quot;,&quot;cta&quot;:&quot;Read full story&quot;,&quot;showBylines&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;What's the Difference Between JVM JRE and JDK? - Quick Answer&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish daily on Medium and use Substack to share recaps, exclusive content, and give readers a way to support the programming-focused writing I do every day.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2025-09-05T18:51:22.487Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!tBfm!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F28f85791-3aad-4cf7-9456-6b41f607f8b3_328x328.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/whats-the-difference-between-jvm&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:172741647,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:false,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p>That post gives you the vocabulary you will keep bumping into, so the rest of your reading stays focused on code instead of naming.</p><p>If you want a much longer optional read that walks you through Java in one connected pass before you jump into the smaller posts, <a href="https://medium.com/@AlexanderObregon/a-guide-to-object-oriented-programming-in-java-89dc4544837f">A Guide to Object-Oriented Programming in Java</a> is a really good next stop. It covers the fundamentals in a broader arc, then you can come back to this guide and use the quick answer posts and practice posts as focused follow ups when you want repetition on one topic at a time.</p><h4>Core Language Behavior That Pays Off Right Away</h4><p>After the JVM and JDK terms start to make sense, it helps to zoom in on what Java is doing with objects and memory. People say <code>new</code> creates an object, but there is a lot packed into that one word, like constructors running, references being set up, and what it means for an object to exist long enough to be collected later. Having that picture in your head also makes it easier to debug those moments when something works differently than you expected.</p><p>Read:</p><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;151c108a-0477-429a-ab7d-2e049d6eca4f&quot;,&quot;caption&quot;:&quot;Creating objects in Java is one of the most common operations, and the new keyword is at the center of it. The question is simple: what actually happens when you type new in code? Behind that one word is a sequence of steps carried out by the Java Virtual Machine (JVM) to allocate memory, call constructors, and return an initialized object r&#8230;&quot;,&quot;cta&quot;:&quot;Read full story&quot;,&quot;showBylines&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;What Really Happens When You Type New in Java - Quick Answer&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish daily on Medium and use Substack to share recaps, exclusive content, and give readers a way to support the programming-focused writing I do every day.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2025-09-04T00:26:13.253Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!sbsh!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F861e88bc-3f71-4c8c-b415-d1bf5d49342a_328x328.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/what-really-happens-when-you-type&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:172734449,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:true,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p>It gives you a solid picture of objects and references that will keep helping as you move through the rest of the section.</p><p>Strings come next because they show up everywhere. Java strings have rules that surprise beginners, like immutability and how repeated edits create new objects. You do not need performance tuning on day one, but you do want to know what stays stable and what changes when you build and combine text.</p><p>Go next to:</p><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;442910ab-4aec-4f5f-a6ec-99d5efceb209&quot;,&quot;caption&quot;:&quot;Strings in Java are objects that never change once they are created. This immutability means that if you try to modify a string, a new string object is formed instead of altering the old one. The design choice gives strings safety, consistency, and efficiency in the runtime environment.&quot;,&quot;cta&quot;:&quot;Read full story&quot;,&quot;showBylines&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;How Do Java Strings Stay Immutable? - Quick Answer&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish daily on Medium and use Substack to share recaps, exclusive content, and give readers a way to support the programming-focused writing I do every day.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2025-09-06T18:19:19.041Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!ZT1G!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fec71cb35-e0d4-4245-b623-a5cdcf44c2f8_328x328.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/how-do-java-strings-stay-immutable&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:172743344,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:true,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p>It gives you a stable way to think about string behavior while you write everyday code.</p><p>Platform independence is worth reading once the JVM terms and the object model are in place. When people say write once run anywhere, they are talking about bytecode that a JVM can run on each platform, plus libraries that cover OS differences. Getting that idea into your head early helps explain why Java apps ship the way they do.</p><p>Read this next:</p><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;53056647-5d0c-4691-a6a3-123ab2ab7819&quot;,&quot;caption&quot;:&quot;A common question about Java is why people call it platform independent. The phrase &#8220;write once run anywhere&#8221; gives a quick label to the idea, but what makes that possible runs deeper than the language itself. It depends on how code is compiled into bytecode and then handled by the Java Virtual Machine, with support from the JRE and JDK. Tog&#8230;&quot;,&quot;cta&quot;:&quot;Read full story&quot;,&quot;showBylines&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Why Java Is Platform Independent - Quick Answer&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish daily on Medium and use Substack to share recaps, exclusive content, and give readers a way to support the programming-focused writing I do every day.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2025-09-04T18:22:26.170Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!Sujm!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcb7c3a0a-c968-43db-a17c-18f296c5f791_328x328.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/why-java-is-platform-independent&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:172740543,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:true,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><h4>Practice Posts That Make Syntax Feel Normal</h4><p>Concept posts give you the basic idea, then practice helps it stay with you. Small problems build comfort with loop boundaries, condition checks, indexing, and breaking work into steps without getting buried in project scaffolding.</p><p>If you want a first loop practice post, start with:</p><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;4b4044d6-6f7a-46f1-a028-35686bcad3a7&quot;,&quot;caption&quot;:&quot;Printing a list of perfect squares with Java goes beyond writing a loop. It shows how values shift in memory, how counters move through each step, and how math lines up with flow control. Building a table of square numbers gives a clear view of how Java repeats logic, handles basic arithmetic with primitive types, and forms output as it runs&#8230;&quot;,&quot;cta&quot;:&quot;Read full story&quot;,&quot;showBylines&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Making a Table of Squares with Java Loops&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish daily on Medium and use Substack to share recaps, exclusive content, and give readers a way to support the programming-focused writing I do every day.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2025-07-20T03:26:52.259Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!QALu!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c4e19c9-3dc6-4abe-8937-b5feb9f9e400_328x328.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/making-a-table-of-squares-with-java&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:168754156,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:1,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:true,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p>Output is easy to verify, and it reinforces loop control without extra moving parts.</p><p>If you want a first string practice post, follow with:</p><div class="digest-post-embed" data-attrs="{&quot;nodeId&quot;:&quot;3aa59e87-3a75-496b-8147-6462c28d8341&quot;,&quot;caption&quot;:&quot;Reversing text with Java comes up often, from handling user input to writing quick algorithm code. The final result looks clear enough, but the process works through how characters are arranged in memory, how that memory is handled as the program runs, and what each step of the loop or method call does to flip the order.&quot;,&quot;cta&quot;:&quot;Read full story&quot;,&quot;showBylines&quot;:true,&quot;size&quot;:&quot;sm&quot;,&quot;isEditorNode&quot;:true,&quot;title&quot;:&quot;Reversing a Word or Sentence Using Java Code&quot;,&quot;publishedBylines&quot;:[{&quot;id&quot;:185785576,&quot;name&quot;:&quot;Alexander Obregon&quot;,&quot;bio&quot;:&quot;I publish daily on Medium and use Substack to share recaps, exclusive content, and give readers a way to support the programming-focused writing I do every day.&quot;,&quot;photo_url&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8905d669-ef92-4534-9c0c-f486ad372c94_1905x1905.jpeg&quot;,&quot;is_guest&quot;:false,&quot;bestseller_tier&quot;:null}],&quot;post_date&quot;:&quot;2025-07-01T01:44:06.169Z&quot;,&quot;cover_image&quot;:&quot;https://substackcdn.com/image/fetch/$s_!WaUS!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F05bd7aa5-0a06-473b-ae03-72a4a87784d3_328x328.png&quot;,&quot;cover_image_alt&quot;:null,&quot;canonical_url&quot;:&quot;https://alexanderobregon.substack.com/p/reversing-a-word-or-sentence-using&quot;,&quot;section_name&quot;:&quot;Java and JVM&quot;,&quot;video_upload_id&quot;:null,&quot;id&quot;:167231199,&quot;type&quot;:&quot;newsletter&quot;,&quot;reaction_count&quot;:0,&quot;comment_count&quot;:0,&quot;publication_id&quot;:4719466,&quot;publication_name&quot;:&quot;Alexander Obregon's Substack&quot;,&quot;publication_logo_url&quot;:&quot;&quot;,&quot;belowTheFold&quot;:true,&quot;youtube_url&quot;:null,&quot;show_links&quot;:null,&quot;feed_url&quot;:null}"></div><p>Reversal work forces you to think about indexing and building results without losing track of what the loop is doing.</p><h4>Picking Your Next Reads Without Getting Lost</h4><p>After those first posts, pick your next reads based on what you want to practice next. Number focused posts help you get comfortable with <code>int</code> and <code>long</code>, arithmetic, and edge cases. String focused posts help with scanning characters and building results. Array focused posts help you learn indexing and loops with a bit more structure.</p><p>If number work is the rough spot, start with <a href="https://medium.com/@AlexanderObregon/integer-overflow-in-java-explained-4142151fdb4b">Integer Overflow in Java Explained</a>, then move to <a href="https://medium.com/@AlexanderObregon/adding-numbers-from-one-to-n-with-java-loops-2dce47c9b20b">Adding Numbers From One to N With Java Loops</a>, then try a check style post like <a href="https://medium.com/@AlexanderObregon/checking-armstrong-numbers-with-java-logic-10a4dd52e5f9">Checking Armstrong Numbers with Java Logic</a> or <a href="https://medium.com/@AlexanderObregon/checking-harshad-numbers-with-java-942924f197e2">Checking Harshad Numbers with Java</a>.</p><p>If string work is the rough spot, start with <a href="https://medium.com/@AlexanderObregon/checking-if-text-contains-only-numbers-in-java-0cbca63ba3bc">Checking if Text Contains Only Numbers in Java</a>, then <a href="https://medium.com/@AlexanderObregon/counting-capital-letters-in-java-strings-29f8cba27093">Counting Capital Letters in Java Strings</a>, then <a href="https://medium.com/@AlexanderObregon/finding-the-first-non-repeated-character-in-java-strings-46c67d62c60c">Finding the First Non Repeated Character in Java Strings</a>.</p><p>If arrays feel slippery, start with <a href="https://medium.com/@AlexanderObregon/finding-the-minimum-value-in-a-java-array-357d8e65bc7a">Finding the Minimum Value in a Java Array</a>, then <a href="https://medium.com/@AlexanderObregon/finding-the-sum-of-odd-numbers-in-java-arrays-72c54828b825">Finding the Sum of Odd Numbers in Java Arrays</a>, then <a href="https://medium.com/@AlexanderObregon/reversing-the-order-of-an-array-with-java-loops-95b3546e023b?source=user_profile_page---------248-------------4f9731d3205----------------------">Reversing the Order of an Array with Java Loops</a>.</p><h3>What To Read After Java Basics And Fundamentals</h3><p>When the basics start to feel natural, choose your next section based on what you want to get better at. <a href="https://alexanderobregon.substack.com/i/185468970/algorithms-and-data-structures-in-java">Algorithms And Data Structures In Java</a> is a good next stop if you want more practice turning a problem into steps, then turning those steps into working code. That section keeps you working with arrays, trees, graphs, and common problem types, so repetition builds comfort without feeling random.</p><p>If you are curious about what happens during execution <a href="https://alexanderobregon.substack.com/i/185468970/jvm-internals-and-class-behavior">JVM Internals And Class Behavior</a> is the next section to read. It connects your source code to class files, loading, casting, and JIT behavior, so performance and memory results stop feeling random when something behaves differently than you expected.</p><p>Spring Boot reads best after you have spent time in either of those areas, because it is less about learning syntax and more about how an application is put together and how it behaves. Start with <a href="https://alexanderobregon.substack.com/i/185468970/spring-boot-project-setup-and-build-work">Spring Boot Project Setup And Build Work</a> to get familiar with repo layout and build workflow, then move to <a href="https://alexanderobregon.substack.com/i/185468970/spring-boot-apis-contracts-and-gateways">Spring Boot APIs, Contracts, And Gateways</a> when you want to think about how services communicate. <a href="https://alexanderobregon.substack.com/i/185468970/spring-boot-background-work-async-calls-and-scheduling">Spring Boot Background Work, Async Calls, And Scheduling</a> fits when you want to run work off the request thread, and <a href="https://alexanderobregon.substack.com/i/185468970/spring-boot-reliability-resilience-and-performance">Spring Boot Reliability, Resilience, And Performance</a> fits when you want to reason about timeouts, pools, memory, and load.</p><p>Interview prep is also a common next step if you are practicing for coding screens. My Java <a href="https://alexanderobregon.substack.com/s/leetcode-solutions-java">LeetCode Solutions section</a> is made for coding problem practice in Java, with problem statements broken down into steps and then translated into code you can study and reuse, with time and space complexity discussed along the way. When you want repetition on common interview themes, that section pairs well with <a href="https://alexanderobregon.substack.com/i/185468970/algorithms-and-data-structures-in-java">Algorithms And Data Structures In Java</a>.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&amp;utm_medium=email&amp;utm_content=share&amp;action=share"><span>Share Alexander Obregon's Substack</span></a></p>]]></content:encoded></item><item><title><![CDATA[Building a Markdown to HTML API With Spring Boot]]></title><description><![CDATA[Markdown shows up in many writing tools, from blog engines to personal note apps.]]></description><link>https://alexanderobregon.substack.com/p/building-a-markdown-to-html-api-with</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/building-a-markdown-to-html-api-with</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Sat, 21 Feb 2026 17:53:12 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/3289277e-3b8a-45d8-9f41-0dd5e0bea418_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!0Mia!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!0Mia!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!0Mia!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!0Mia!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!0Mia!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!0Mia!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/bc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!0Mia!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!0Mia!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!0Mia!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!0Mia!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbc6c8ff7-8240-4e81-8f7e-43119952879f_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://spring.io/projects/spring-boot">Image Source</a></figcaption></figure></div><p>Markdown shows up in many writing tools, from blog engines to personal note apps. Projects often accept Markdown from a client, turn it into HTML on the server, sanitize the result so it is safe to render, then store or serve that HTML. With a Spring Boot REST API, this flow maps nicely onto a controller that takes Markdown in, calls a Java Markdown library, and returns sanitized HTML to whatever blog, notes app, or CMS is talking to it.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>Project Setup For The Markdown Service</h3><p>Project structure for a Markdown to HTML API rests on a few basic choices. The stack needs a current Java version, a Spring Boot web starter for HTTP handling, and a small group of libraries that know how to parse Markdown and scrub HTML. All of that lives inside a standard Spring Boot project, so routing, configuration, and deployment behave the same way as any other REST API built on the same framework. The goal at this stage is to decide where responsibilities sit. Spring Boot takes care of HTTP, JSON mapping, lifecycle management, and dependency injection. Markdown libraries like <code>commonmark-java</code> and <code>flexmark-java</code> focus on translating text into HTML. On top of that, a sanitizer such as the OWASP Java HTML Sanitizer or Jsoup filters the HTML so only safe tags and attributes reach the client. With those three concerns mapped out, the project layout becomes more predictable and easier to extend later.</p><h4>Spring Boot Dependencies For Markdown</h4><p>Project creation usually starts from a standard Spring Boot starter with web support. Current releases work well with Java 17 or newer, and the <code>spring-boot-starter-web</code> dependency brings in Spring MVC, Jackson, and embedded Tomcat for HTTP traffic. On top of that base, the build file brings in the Markdown libraries and a sanitizer.</p><p>For example, a typical Maven <code>pom.xml</code> section can look like this:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!nK7U!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!nK7U!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png 424w, https://substackcdn.com/image/fetch/$s_!nK7U!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png 848w, https://substackcdn.com/image/fetch/$s_!nK7U!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png 1272w, https://substackcdn.com/image/fetch/$s_!nK7U!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!nK7U!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png" width="978" height="585.0535714285714" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:871,&quot;width&quot;:1456,&quot;resizeWidth&quot;:978,&quot;bytes&quot;:190609,&quot;alt&quot;:&quot;<dependencies>     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-web</artifactId>     </dependency>      <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-validation</artifactId>     </dependency>      <dependency>         <groupId>org.commonmark</groupId>         <artifactId>commonmark</artifactId>         <version>0.27.1</version>     </dependency>      <dependency>         <groupId>org.commonmark</groupId>         <artifactId>commonmark-ext-gfm-tables</artifactId>         <version>0.27.1</version>     </dependency>      <dependency>         <groupId>com.vladsch.flexmark</groupId>         <artifactId>flexmark-all</artifactId>         <version>0.64.8</version>     </dependency>      <dependency>         <groupId>com.googlecode.owasp-java-html-sanitizer</groupId>         <artifactId>owasp-java-html-sanitizer</artifactId>         <version>20260102.1</version>     </dependency> </dependencies>&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="<dependencies>     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-web</artifactId>     </dependency>      <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-validation</artifactId>     </dependency>      <dependency>         <groupId>org.commonmark</groupId>         <artifactId>commonmark</artifactId>         <version>0.27.1</version>     </dependency>      <dependency>         <groupId>org.commonmark</groupId>         <artifactId>commonmark-ext-gfm-tables</artifactId>         <version>0.27.1</version>     </dependency>      <dependency>         <groupId>com.vladsch.flexmark</groupId>         <artifactId>flexmark-all</artifactId>         <version>0.64.8</version>     </dependency>      <dependency>         <groupId>com.googlecode.owasp-java-html-sanitizer</groupId>         <artifactId>owasp-java-html-sanitizer</artifactId>         <version>20260102.1</version>     </dependency> </dependencies>" title="<dependencies>     <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-web</artifactId>     </dependency>      <dependency>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-validation</artifactId>     </dependency>      <dependency>         <groupId>org.commonmark</groupId>         <artifactId>commonmark</artifactId>         <version>0.27.1</version>     </dependency>      <dependency>         <groupId>org.commonmark</groupId>         <artifactId>commonmark-ext-gfm-tables</artifactId>         <version>0.27.1</version>     </dependency>      <dependency>         <groupId>com.vladsch.flexmark</groupId>         <artifactId>flexmark-all</artifactId>         <version>0.64.8</version>     </dependency>      <dependency>         <groupId>com.googlecode.owasp-java-html-sanitizer</groupId>         <artifactId>owasp-java-html-sanitizer</artifactId>         <version>20260102.1</version>     </dependency> </dependencies>" srcset="https://substackcdn.com/image/fetch/$s_!nK7U!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png 424w, https://substackcdn.com/image/fetch/$s_!nK7U!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png 848w, https://substackcdn.com/image/fetch/$s_!nK7U!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png 1272w, https://substackcdn.com/image/fetch/$s_!nK7U!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd9e5b2f3-ab29-4110-ab2e-5ee34720fd15_1633x977.png 1456w" sizes="100vw"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>This set of dependencies gives Spring Boot web support, CommonMark parsing, Flexmark parsing with a broad extension set, and a sanitizer that can strip scripts and unsafe attributes. Flexmark can stand in for CommonMark, but having both in the same project helps when a group of developers wants to compare their behavior or support different feature sets behind different endpoints.</p><p>Some projects are built with Gradle instead of Maven, and the same idea carries over with a different syntax. The <code>build.gradle</code> file with the Kotlin DSL can carry the same libraries in a compact form:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!c8BG!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!c8BG!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png 424w, https://substackcdn.com/image/fetch/$s_!c8BG!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png 848w, https://substackcdn.com/image/fetch/$s_!c8BG!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png 1272w, https://substackcdn.com/image/fetch/$s_!c8BG!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!c8BG!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png" width="884" height="151.17857142857142" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/336a40b3-f753-432b-b66b-14f3af208965_1658x283.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:249,&quot;width&quot;:1456,&quot;resizeWidth&quot;:884,&quot;bytes&quot;:108309,&quot;alt&quot;:&quot;dependencies {     implementation(\&quot;org.springframework.boot:spring-boot-starter-web\&quot;)     implementation(\&quot;org.springframework.boot:spring-boot-starter-validation\&quot;)     implementation(\&quot;org.commonmark:commonmark:0.27.1\&quot;)     implementation(\&quot;org.commonmark:commonmark-ext-gfm-tables:0.27.1\&quot;)     implementation(\&quot;com.vladsch.flexmark:flexmark-all:0.64.8\&quot;)     implementation(\&quot;com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1\&quot;) }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="dependencies {     implementation(&quot;org.springframework.boot:spring-boot-starter-web&quot;)     implementation(&quot;org.springframework.boot:spring-boot-starter-validation&quot;)     implementation(&quot;org.commonmark:commonmark:0.27.1&quot;)     implementation(&quot;org.commonmark:commonmark-ext-gfm-tables:0.27.1&quot;)     implementation(&quot;com.vladsch.flexmark:flexmark-all:0.64.8&quot;)     implementation(&quot;com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1&quot;) }" title="dependencies {     implementation(&quot;org.springframework.boot:spring-boot-starter-web&quot;)     implementation(&quot;org.springframework.boot:spring-boot-starter-validation&quot;)     implementation(&quot;org.commonmark:commonmark:0.27.1&quot;)     implementation(&quot;org.commonmark:commonmark-ext-gfm-tables:0.27.1&quot;)     implementation(&quot;com.vladsch.flexmark:flexmark-all:0.64.8&quot;)     implementation(&quot;com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1&quot;) }" srcset="https://substackcdn.com/image/fetch/$s_!c8BG!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png 424w, https://substackcdn.com/image/fetch/$s_!c8BG!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png 848w, https://substackcdn.com/image/fetch/$s_!c8BG!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png 1272w, https://substackcdn.com/image/fetch/$s_!c8BG!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F336a40b3-f753-432b-b66b-14f3af208965_1658x283.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>Both build styles place the Markdown libraries at the same level as any other dependency. Spring itself does not need any special configuration to work with them, because they are plain Java libraries without Spring specific starters.</p><p>Projects commonly rely on a small Spring Boot entry point that ties the project into a runnable application and gives the REST controller a place to live. One common layout keeps a single top level application class in the root package:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!zLex!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!zLex!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png 424w, https://substackcdn.com/image/fetch/$s_!zLex!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png 848w, https://substackcdn.com/image/fetch/$s_!zLex!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png 1272w, https://substackcdn.com/image/fetch/$s_!zLex!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!zLex!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png" width="1456" height="245" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:245,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:61377,&quot;alt&quot;:&quot;@SpringBootApplication public class MarkdownApiApplication {      public static void main(String[] args) {         SpringApplication.run(MarkdownApiApplication.class, args);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@SpringBootApplication public class MarkdownApiApplication {      public static void main(String[] args) {         SpringApplication.run(MarkdownApiApplication.class, args);     } }" title="@SpringBootApplication public class MarkdownApiApplication {      public static void main(String[] args) {         SpringApplication.run(MarkdownApiApplication.class, args);     } }" srcset="https://substackcdn.com/image/fetch/$s_!zLex!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png 424w, https://substackcdn.com/image/fetch/$s_!zLex!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png 848w, https://substackcdn.com/image/fetch/$s_!zLex!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png 1272w, https://substackcdn.com/image/fetch/$s_!zLex!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc6b2c58b-86b5-433c-93c7-1f983a3a4d2a_1769x298.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>That class triggers component scanning for controllers and services in the same package tree. After the dependencies are in place and this main class exists, Spring Boot can start an embedded server, load the Markdown components, and begin handling HTTP requests.</p><h4>Controller Structure For The Api</h4><p>Controllers bridge the HTTP layer and the Markdown service. They accept JSON input, turn that into Java objects, call a service method, then wrap the result back into JSON. For a Markdown to HTML API, the input is usually a block of Markdown text plus optional flags, and the output is HTML text that has already passed through a sanitizer.</p><p>Input that carries only raw Markdown can be represented with a compact record:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!lwUa!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!lwUa!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png 424w, https://substackcdn.com/image/fetch/$s_!lwUa!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png 848w, https://substackcdn.com/image/fetch/$s_!lwUa!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png 1272w, https://substackcdn.com/image/fetch/$s_!lwUa!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!lwUa!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png" width="1456" height="94" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/bd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:94,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:24116,&quot;alt&quot;:&quot;public record MarkdownRequest(String markdown) { }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="public record MarkdownRequest(String markdown) { }" title="public record MarkdownRequest(String markdown) { }" srcset="https://substackcdn.com/image/fetch/$s_!lwUa!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png 424w, https://substackcdn.com/image/fetch/$s_!lwUa!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png 848w, https://substackcdn.com/image/fetch/$s_!lwUa!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png 1272w, https://substackcdn.com/image/fetch/$s_!lwUa!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fbd708f2b-da4b-4cbd-bece-96f5f0f83253_1728x112.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>This record pairs a field name with the Markdown text, which keeps the request body small while still working well with JSON mapping.</p><p>Many APIs also send structured HTML back instead of a bare string, which keeps the response structure flexible if extra metadata needs to appear later:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!yvox!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!yvox!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png 424w, https://substackcdn.com/image/fetch/$s_!yvox!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png 848w, https://substackcdn.com/image/fetch/$s_!yvox!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png 1272w, https://substackcdn.com/image/fetch/$s_!yvox!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!yvox!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png" width="1456" height="94" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:94,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:22425,&quot;alt&quot;:&quot;public record HtmlResponse(String html) { }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="public record HtmlResponse(String html) { }" title="public record HtmlResponse(String html) { }" srcset="https://substackcdn.com/image/fetch/$s_!yvox!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png 424w, https://substackcdn.com/image/fetch/$s_!yvox!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png 848w, https://substackcdn.com/image/fetch/$s_!yvox!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png 1272w, https://substackcdn.com/image/fetch/$s_!yvox!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3a82afb2-a2c3-44dc-86ed-7bb8527b940d_1727x111.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>This response wrapper keeps the door open for future fields such as a content id, warnings about stripped tags, or a separate plain text preview.</p><p>The controller can then accept <code>MarkdownRequest</code> and return <code>HtmlResponse</code> while delegating all conversion details to a service bean. Routing can keep endpoints separate for CommonMark and Flexmark so clients can pick the behavior they prefer.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Re1G!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Re1G!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png 424w, https://substackcdn.com/image/fetch/$s_!Re1G!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png 848w, https://substackcdn.com/image/fetch/$s_!Re1G!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png 1272w, https://substackcdn.com/image/fetch/$s_!Re1G!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Re1G!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png" width="906" height="448.6442307692308" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:721,&quot;width&quot;:1456,&quot;resizeWidth&quot;:906,&quot;bytes&quot;:235043,&quot;alt&quot;:&quot;import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*;  @RestController @RequestMapping(\&quot;/api/markdown\&quot;) public class MarkdownController {      private final MarkdownRendererSelector rendererSelector;      public MarkdownController(MarkdownRendererSelector rendererSelector) {         this.rendererSelector = rendererSelector;     }      @PostMapping(\&quot;/render/commonmark\&quot;)     public HtmlResponse renderWithCommonmark(@Valid @RequestBody MarkdownRequest request) {         String html = rendererSelector.render(request.markdown(), \&quot;commonmark\&quot;);         return new HtmlResponse(html);     }      @PostMapping(\&quot;/render/flexmark\&quot;)     public HtmlResponse renderWithFlexmark(@Valid @RequestBody MarkdownRequest request) {         String html = rendererSelector.render(request.markdown(), \&quot;flexmark\&quot;);         return new HtmlResponse(html);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*;  @RestController @RequestMapping(&quot;/api/markdown&quot;) public class MarkdownController {      private final MarkdownRendererSelector rendererSelector;      public MarkdownController(MarkdownRendererSelector rendererSelector) {         this.rendererSelector = rendererSelector;     }      @PostMapping(&quot;/render/commonmark&quot;)     public HtmlResponse renderWithCommonmark(@Valid @RequestBody MarkdownRequest request) {         String html = rendererSelector.render(request.markdown(), &quot;commonmark&quot;);         return new HtmlResponse(html);     }      @PostMapping(&quot;/render/flexmark&quot;)     public HtmlResponse renderWithFlexmark(@Valid @RequestBody MarkdownRequest request) {         String html = rendererSelector.render(request.markdown(), &quot;flexmark&quot;);         return new HtmlResponse(html);     } }" title="import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*;  @RestController @RequestMapping(&quot;/api/markdown&quot;) public class MarkdownController {      private final MarkdownRendererSelector rendererSelector;      public MarkdownController(MarkdownRendererSelector rendererSelector) {         this.rendererSelector = rendererSelector;     }      @PostMapping(&quot;/render/commonmark&quot;)     public HtmlResponse renderWithCommonmark(@Valid @RequestBody MarkdownRequest request) {         String html = rendererSelector.render(request.markdown(), &quot;commonmark&quot;);         return new HtmlResponse(html);     }      @PostMapping(&quot;/render/flexmark&quot;)     public HtmlResponse renderWithFlexmark(@Valid @RequestBody MarkdownRequest request) {         String html = rendererSelector.render(request.markdown(), &quot;flexmark&quot;);         return new HtmlResponse(html);     } }" srcset="https://substackcdn.com/image/fetch/$s_!Re1G!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png 424w, https://substackcdn.com/image/fetch/$s_!Re1G!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png 848w, https://substackcdn.com/image/fetch/$s_!Re1G!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png 1272w, https://substackcdn.com/image/fetch/$s_!Re1G!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c3567b5-bc80-4b32-8565-6e517d893e27_1770x877.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>In this, the controller keeps HTTP concerns narrow. Parsing and sanitization stay inside <code>MarkdownRendererSelector</code>, while validation happens at the request edge through Bean Validation. That separation helps beginners see the boundary between the web layer and the Markdown logic.</p><p>Some projects add a small amount of validation at the controller edge, either with Bean Validation annotations or manual checks, so empty Markdown payloads do not move further into the system. Bean Validation works well in this context and stays close to the REST boundary.</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ots2!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ots2!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png 424w, https://substackcdn.com/image/fetch/$s_!ots2!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png 848w, https://substackcdn.com/image/fetch/$s_!ots2!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png 1272w, https://substackcdn.com/image/fetch/$s_!ots2!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ots2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png" width="1456" height="212" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:212,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:42187,&quot;alt&quot;:&quot;import jakarta.validation.constraints.NotBlank;  public record MarkdownRequest(         @NotBlank String markdown ) { }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="import jakarta.validation.constraints.NotBlank;  public record MarkdownRequest(         @NotBlank String markdown ) { }" title="import jakarta.validation.constraints.NotBlank;  public record MarkdownRequest(         @NotBlank String markdown ) { }" srcset="https://substackcdn.com/image/fetch/$s_!ots2!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png 424w, https://substackcdn.com/image/fetch/$s_!ots2!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png 848w, https://substackcdn.com/image/fetch/$s_!ots2!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png 1272w, https://substackcdn.com/image/fetch/$s_!ots2!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F10715018-2fb0-4500-9c2d-413f1e553336_1754x255.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>With annotations like <code>@NotBlank</code>, Spring Boot can respond with a 400 status code when a client sends an empty string. That keeps the Markdown service from handling invalid input and keeps error handling localized near the HTTP layer.</p><h3>Markdown Parsing In The API Layer</h3><p>Controllers pass raw Markdown into a service, and that service turns the text into HTML before it ever reaches a template or front-end component. Parsing and rendering live in that service layer, while HTTP details stay in the controller. CommonMark Java and Flexmark Java both produce HTML from Markdown strings, and a sanitizer runs afterward so the final output is safe to embed in a browser page.</p><h4>CommonMark Java Parser Flow</h4><p>CommonMark defines a shared Markdown specification that many tools follow. The <code>commonmark-java</code> library takes a Markdown string, builds an internal node tree, and then renders that tree into HTML. Spring only needs a small service bean that holds a <code>Parser</code> and an <code>HtmlRenderer</code> and exposes a method that the controller can call.</p><p>Take this service example focused on CommonMark:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Rxot!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Rxot!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png 424w, https://substackcdn.com/image/fetch/$s_!Rxot!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png 848w, https://substackcdn.com/image/fetch/$s_!Rxot!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png 1272w, https://substackcdn.com/image/fetch/$s_!Rxot!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Rxot!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png" width="930" height="388.35164835164835" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:608,&quot;width&quot;:1456,&quot;resizeWidth&quot;:930,&quot;bytes&quot;:157535,&quot;alt&quot;:&quot;import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.springframework.stereotype.Service;  @Service public class CommonmarkMarkdownService {      private final Parser parser;     private final HtmlRenderer renderer;      public CommonmarkMarkdownService() {         this.parser = Parser.builder().build();         this.renderer = HtmlRenderer.builder().build();     }      public String toHtml(String markdown) {         Node document = parser.parse(markdown);         return renderer.render(document);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.springframework.stereotype.Service;  @Service public class CommonmarkMarkdownService {      private final Parser parser;     private final HtmlRenderer renderer;      public CommonmarkMarkdownService() {         this.parser = Parser.builder().build();         this.renderer = HtmlRenderer.builder().build();     }      public String toHtml(String markdown) {         Node document = parser.parse(markdown);         return renderer.render(document);     } }" title="import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.springframework.stereotype.Service;  @Service public class CommonmarkMarkdownService {      private final Parser parser;     private final HtmlRenderer renderer;      public CommonmarkMarkdownService() {         this.parser = Parser.builder().build();         this.renderer = HtmlRenderer.builder().build();     }      public String toHtml(String markdown) {         Node document = parser.parse(markdown);         return renderer.render(document);     } }" srcset="https://substackcdn.com/image/fetch/$s_!Rxot!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png 424w, https://substackcdn.com/image/fetch/$s_!Rxot!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png 848w, https://substackcdn.com/image/fetch/$s_!Rxot!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png 1272w, https://substackcdn.com/image/fetch/$s_!Rxot!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0da8bf1-1693-4ff6-b0be-5c5426bfd43e_1762x736.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Here, the class keeps two long lived objects, the parser and the renderer, and calls them on every request that needs CommonMark output. The <code>toHtml</code> method receives the Markdown text from a controller or another service, builds the node tree, then turns that tree into HTML in one call.</p><p>It&#8217;s common for applications to need GitHub style Markdown features such as tables or strikethrough. CommonMark Java offers extensions that plug into the same parser and renderer builder pattern. An extension enriches the node tree with additional node types and tells the renderer how to emit HTML for them.</p><p>The configuration can grow a little as extensions come into play:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!A3fA!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!A3fA!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png 424w, https://substackcdn.com/image/fetch/$s_!A3fA!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png 848w, https://substackcdn.com/image/fetch/$s_!A3fA!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png 1272w, https://substackcdn.com/image/fetch/$s_!A3fA!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!A3fA!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png" width="1200" height="579.3956043956044" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:703,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:211936,&quot;alt&quot;:&quot;import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.springframework.stereotype.Service;  import java.util.List;  @Service public class CommonmarkTablesService {      private final Parser parser;     private final HtmlRenderer renderer;      public CommonmarkTablesService() {         var extensions = List.of(TablesExtension.create());          this.parser = Parser.builder()                 .extensions(extensions)                 .build();          this.renderer = HtmlRenderer.builder()                 .extensions(extensions)                 .build();     }      public String markdownWithTablesToHtml(String markdown) {         Node document = parser.parse(markdown);         return renderer.render(document);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.springframework.stereotype.Service;  import java.util.List;  @Service public class CommonmarkTablesService {      private final Parser parser;     private final HtmlRenderer renderer;      public CommonmarkTablesService() {         var extensions = List.of(TablesExtension.create());          this.parser = Parser.builder()                 .extensions(extensions)                 .build();          this.renderer = HtmlRenderer.builder()                 .extensions(extensions)                 .build();     }      public String markdownWithTablesToHtml(String markdown) {         Node document = parser.parse(markdown);         return renderer.render(document);     } }" title="import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.springframework.stereotype.Service;  import java.util.List;  @Service public class CommonmarkTablesService {      private final Parser parser;     private final HtmlRenderer renderer;      public CommonmarkTablesService() {         var extensions = List.of(TablesExtension.create());          this.parser = Parser.builder()                 .extensions(extensions)                 .build();          this.renderer = HtmlRenderer.builder()                 .extensions(extensions)                 .build();     }      public String markdownWithTablesToHtml(String markdown) {         Node document = parser.parse(markdown);         return renderer.render(document);     } }" srcset="https://substackcdn.com/image/fetch/$s_!A3fA!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png 424w, https://substackcdn.com/image/fetch/$s_!A3fA!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png 848w, https://substackcdn.com/image/fetch/$s_!A3fA!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png 1272w, https://substackcdn.com/image/fetch/$s_!A3fA!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7e84980a-7321-4ad2-b88b-15d989fcb51d_1807x872.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Extensions are passed into both the parser and renderer builders so the node tree and HTML output stay in sync. That alignment means a CommonMark based API can accept Markdown with tables in the request body and still produce well formed table elements in the HTML response.</p><p>CommonMark Java keeps its public API small, which suits service code that just wants to call a parser and renderer without a lot of moving parts. More advanced use cases can walk the node tree for custom processing, but a plain pipeline like this is usually enough for blog posts, documentation blocks, and note content.</p><h4>Flexmark Java Parser Flow</h4><p>Flexmark Java builds on the CommonMark grammar and adds a large extension family, which suits projects that want GitHub style features, table of contents generation, definition lists, and many other extras. The <code>flexmark-all</code> artifact pulls in the core parser, renderer, and a wide collection of extensions so the build in a Spring Boot project stays short.</p><p>Configuration begins with a <code>MutableDataSet</code> that carries options and the list of active extensions. That data object travels into both the parser builder and the HTML renderer builder.</p><p>The core parts fit into a service class like this:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!q3gG!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!q3gG!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png 424w, https://substackcdn.com/image/fetch/$s_!q3gG!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png 848w, https://substackcdn.com/image/fetch/$s_!q3gG!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png 1272w, https://substackcdn.com/image/fetch/$s_!q3gG!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!q3gG!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png" width="1200" height="584.3406593406594" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:709,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:252746,&quot;alt&quot;:&quot;import com.vladsch.flexmark.ext.autolink.AutolinkExtension; import com.vladsch.flexmark.ext.tables.TablesExtension; import com.vladsch.flexmark.html.HtmlRenderer; import com.vladsch.flexmark.parser.Parser; import com.vladsch.flexmark.util.data.MutableDataSet; import org.springframework.stereotype.Service;  import java.util.List;  @Service public class FlexmarkMarkdownService {      private final Parser parser;     private final HtmlRenderer renderer;      public FlexmarkMarkdownService() {         MutableDataSet options = new MutableDataSet();         options.set(Parser.EXTENSIONS, List.of(                 TablesExtension.create(),                 AutolinkExtension.create()         ));          this.parser = Parser.builder(options).build();         this.renderer = HtmlRenderer.builder(options).build();     }      public String toHtml(String markdown) {         com.vladsch.flexmark.ast.Node document = parser.parse(markdown);         return renderer.render(document);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import com.vladsch.flexmark.ext.autolink.AutolinkExtension; import com.vladsch.flexmark.ext.tables.TablesExtension; import com.vladsch.flexmark.html.HtmlRenderer; import com.vladsch.flexmark.parser.Parser; import com.vladsch.flexmark.util.data.MutableDataSet; import org.springframework.stereotype.Service;  import java.util.List;  @Service public class FlexmarkMarkdownService {      private final Parser parser;     private final HtmlRenderer renderer;      public FlexmarkMarkdownService() {         MutableDataSet options = new MutableDataSet();         options.set(Parser.EXTENSIONS, List.of(                 TablesExtension.create(),                 AutolinkExtension.create()         ));          this.parser = Parser.builder(options).build();         this.renderer = HtmlRenderer.builder(options).build();     }      public String toHtml(String markdown) {         com.vladsch.flexmark.ast.Node document = parser.parse(markdown);         return renderer.render(document);     } }" title="import com.vladsch.flexmark.ext.autolink.AutolinkExtension; import com.vladsch.flexmark.ext.tables.TablesExtension; import com.vladsch.flexmark.html.HtmlRenderer; import com.vladsch.flexmark.parser.Parser; import com.vladsch.flexmark.util.data.MutableDataSet; import org.springframework.stereotype.Service;  import java.util.List;  @Service public class FlexmarkMarkdownService {      private final Parser parser;     private final HtmlRenderer renderer;      public FlexmarkMarkdownService() {         MutableDataSet options = new MutableDataSet();         options.set(Parser.EXTENSIONS, List.of(                 TablesExtension.create(),                 AutolinkExtension.create()         ));          this.parser = Parser.builder(options).build();         this.renderer = HtmlRenderer.builder(options).build();     }      public String toHtml(String markdown) {         com.vladsch.flexmark.ast.Node document = parser.parse(markdown);         return renderer.render(document);     } }" srcset="https://substackcdn.com/image/fetch/$s_!q3gG!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png 424w, https://substackcdn.com/image/fetch/$s_!q3gG!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png 848w, https://substackcdn.com/image/fetch/$s_!q3gG!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png 1272w, https://substackcdn.com/image/fetch/$s_!q3gG!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3f30f6fd-1621-4d07-b7e4-31207b6f70fb_1789x871.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Here the Markdown string gets parsed into a Flexmark node tree that includes extra nodes for things like tables and auto linked URLs, and then the renderer converts that structure into HTML in a single call. Extensions are centralized in the <code>options</code> object so the configuration for parser and renderer stays consistent.</p><p>Some APIs want to support more than one flavor and allow clients to select a style that matches their editor. That choice can sit in a higher level service that delegates to CommonMark or Flexmark based on a flag in the request.</p><p>A small dispatcher service can layer on top of the two specific services:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!dSQW!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!dSQW!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png 424w, https://substackcdn.com/image/fetch/$s_!dSQW!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png 848w, https://substackcdn.com/image/fetch/$s_!dSQW!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png 1272w, https://substackcdn.com/image/fetch/$s_!dSQW!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!dSQW!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png" width="1200" height="562.0879120879121" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:682,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:252481,&quot;alt&quot;:&quot;import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; import org.springframework.stereotype.Service;  @Service public class MarkdownRendererSelector {      private final CommonmarkMarkdownService commonmarkService;     private final FlexmarkMarkdownService flexmarkService;     private final PolicyFactory policy;      public MarkdownRendererSelector(CommonmarkMarkdownService commonmarkService,                                     FlexmarkMarkdownService flexmarkService) {         this.commonmarkService = commonmarkService;         this.flexmarkService = flexmarkService;         this.policy = Sanitizers.FORMATTING                 .and(Sanitizers.LINKS)                 .and(Sanitizers.BLOCKS);     }      public String render(String markdown, String flavor) {         String unsafeHtml;         if (\&quot;flexmark\&quot;.equalsIgnoreCase(flavor)) {             unsafeHtml = flexmarkService.toHtml(markdown);         } else {             unsafeHtml = commonmarkService.toHtml(markdown);         }         return policy.sanitize(unsafeHtml);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; import org.springframework.stereotype.Service;  @Service public class MarkdownRendererSelector {      private final CommonmarkMarkdownService commonmarkService;     private final FlexmarkMarkdownService flexmarkService;     private final PolicyFactory policy;      public MarkdownRendererSelector(CommonmarkMarkdownService commonmarkService,                                     FlexmarkMarkdownService flexmarkService) {         this.commonmarkService = commonmarkService;         this.flexmarkService = flexmarkService;         this.policy = Sanitizers.FORMATTING                 .and(Sanitizers.LINKS)                 .and(Sanitizers.BLOCKS);     }      public String render(String markdown, String flavor) {         String unsafeHtml;         if (&quot;flexmark&quot;.equalsIgnoreCase(flavor)) {             unsafeHtml = flexmarkService.toHtml(markdown);         } else {             unsafeHtml = commonmarkService.toHtml(markdown);         }         return policy.sanitize(unsafeHtml);     } }" title="import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; import org.springframework.stereotype.Service;  @Service public class MarkdownRendererSelector {      private final CommonmarkMarkdownService commonmarkService;     private final FlexmarkMarkdownService flexmarkService;     private final PolicyFactory policy;      public MarkdownRendererSelector(CommonmarkMarkdownService commonmarkService,                                     FlexmarkMarkdownService flexmarkService) {         this.commonmarkService = commonmarkService;         this.flexmarkService = flexmarkService;         this.policy = Sanitizers.FORMATTING                 .and(Sanitizers.LINKS)                 .and(Sanitizers.BLOCKS);     }      public String render(String markdown, String flavor) {         String unsafeHtml;         if (&quot;flexmark&quot;.equalsIgnoreCase(flavor)) {             unsafeHtml = flexmarkService.toHtml(markdown);         } else {             unsafeHtml = commonmarkService.toHtml(markdown);         }         return policy.sanitize(unsafeHtml);     } }" srcset="https://substackcdn.com/image/fetch/$s_!dSQW!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png 424w, https://substackcdn.com/image/fetch/$s_!dSQW!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png 848w, https://substackcdn.com/image/fetch/$s_!dSQW!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png 1272w, https://substackcdn.com/image/fetch/$s_!dSQW!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F98d8d654-be88-430b-80f1-34411676bb8d_1799x843.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Controllers can accept a <code>flavor</code> field in the JSON body or a query parameter and then call this selector, which hands the Markdown string to the correct parser and renderer pair. That pattern keeps the parsing logic isolated in its own service classes while still allowing the API surface to grow features related to Markdown dialects.</p><p>Flexmark exposes many configuration points, such as custom link resolvers and node visitors, and those advanced hooks allow CMS or note taking tools to inject project specific behavior. The core flow in a REST API still follows a simple rhythm of parse, render, and forward the HTML to the sanitizer.</p><h4>HTML Sanitization With Security In Mind</h4><p>Markdown content passes through a parser and renderer before it turns into HTML, but that HTML still needs a safety pass so untrusted content cannot inject scripts or unsafe attributes into a page. Both CommonMark and Flexmark accept raw HTML in the input, and that raw HTML can contain script tags, event handlers, or links crafted to run JavaScript in the browser. Sanitization strips unsafe pieces and leaves only a set of approved elements and attributes.</p><p>OWASP Java HTML Sanitizer focuses on this exact work. The library builds a <code>PolicyFactory</code> by combining policies such as <code>Sanitizers.FORMATTING</code>, <code>Sanitizers.LINKS</code>, and <code>Sanitizers.BLOCKS</code>. The policy acts as a whitelist, and the sanitizer walks the HTML string and removes or escapes any content that does not match that configuration.</p><p>For example, a service method that takes unsafe HTML and returns a sanitized string can look like this:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!vRyg!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!vRyg!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png 424w, https://substackcdn.com/image/fetch/$s_!vRyg!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png 848w, https://substackcdn.com/image/fetch/$s_!vRyg!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png 1272w, https://substackcdn.com/image/fetch/$s_!vRyg!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!vRyg!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png" width="1200" height="524.1758241758242" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:636,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1200,&quot;bytes&quot;:223556,&quot;alt&quot;:&quot;import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; import org.springframework.stereotype.Service;  @Service public class CommonmarkSecureService {      private final Parser parser;     private final HtmlRenderer renderer;     private final PolicyFactory policy;      public CommonmarkSecureService() {         this.parser = Parser.builder().build();         this.renderer = HtmlRenderer.builder().build();         this.policy = Sanitizers.FORMATTING                 .and(Sanitizers.LINKS)                 .and(Sanitizers.BLOCKS);     }      public String toSafeHtml(String markdown) {         Node document = parser.parse(markdown);         String unsafeHtml = renderer.render(document);         return policy.sanitize(unsafeHtml);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; import org.springframework.stereotype.Service;  @Service public class CommonmarkSecureService {      private final Parser parser;     private final HtmlRenderer renderer;     private final PolicyFactory policy;      public CommonmarkSecureService() {         this.parser = Parser.builder().build();         this.renderer = HtmlRenderer.builder().build();         this.policy = Sanitizers.FORMATTING                 .and(Sanitizers.LINKS)                 .and(Sanitizers.BLOCKS);     }      public String toSafeHtml(String markdown) {         Node document = parser.parse(markdown);         String unsafeHtml = renderer.render(document);         return policy.sanitize(unsafeHtml);     } }" title="import org.commonmark.node.Node; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; import org.springframework.stereotype.Service;  @Service public class CommonmarkSecureService {      private final Parser parser;     private final HtmlRenderer renderer;     private final PolicyFactory policy;      public CommonmarkSecureService() {         this.parser = Parser.builder().build();         this.renderer = HtmlRenderer.builder().build();         this.policy = Sanitizers.FORMATTING                 .and(Sanitizers.LINKS)                 .and(Sanitizers.BLOCKS);     }      public String toSafeHtml(String markdown) {         Node document = parser.parse(markdown);         String unsafeHtml = renderer.render(document);         return policy.sanitize(unsafeHtml);     } }" srcset="https://substackcdn.com/image/fetch/$s_!vRyg!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png 424w, https://substackcdn.com/image/fetch/$s_!vRyg!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png 848w, https://substackcdn.com/image/fetch/$s_!vRyg!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png 1272w, https://substackcdn.com/image/fetch/$s_!vRyg!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9c8203f1-4afe-4d06-899d-bfe05266da59_1800x786.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Markdown text travels through parsing and rendering first, then the sanitizer removes any content that falls outside the allowed policies. A controller that calls <code>toSafeHtml</code> does not need to know which sanitizer implementation is in use or which HTML tags the policy accepts, which keeps those details inside the service.</p><p>Some teams prefer Jsoup for sanitization. Jsoup parses HTML into a node tree and then filters that tree through a <code>Safelist</code>. Current versions use <code>Safelist</code> rather than the older <code>Whitelist</code> class, and the <code>Jsoup.clean</code> method applies the safelist rules to produce a safer HTML string.</p><p>And a small helper built around Jsoup can look like this:</p><div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!GZFE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!GZFE!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png 424w, https://substackcdn.com/image/fetch/$s_!GZFE!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png 848w, https://substackcdn.com/image/fetch/$s_!GZFE!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png 1272w, https://substackcdn.com/image/fetch/$s_!GZFE!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!GZFE!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png" width="906" height="164.27472527472528" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:264,&quot;width&quot;:1456,&quot;resizeWidth&quot;:906,&quot;bytes&quot;:71332,&quot;alt&quot;:&quot;import org.jsoup.Jsoup; import org.jsoup.safety.Safelist;  public class JsoupSanitizer {      public String sanitizeBasic(String html) {         return Jsoup.clean(html, Safelist.basic());     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187570206?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="import org.jsoup.Jsoup; import org.jsoup.safety.Safelist;  public class JsoupSanitizer {      public String sanitizeBasic(String html) {         return Jsoup.clean(html, Safelist.basic());     } }" title="import org.jsoup.Jsoup; import org.jsoup.safety.Safelist;  public class JsoupSanitizer {      public String sanitizeBasic(String html) {         return Jsoup.clean(html, Safelist.basic());     } }" srcset="https://substackcdn.com/image/fetch/$s_!GZFE!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png 424w, https://substackcdn.com/image/fetch/$s_!GZFE!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png 848w, https://substackcdn.com/image/fetch/$s_!GZFE!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png 1272w, https://substackcdn.com/image/fetch/$s_!GZFE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c69eca8-f4f4-4f66-98f4-64e6a9e162e3_1744x316.png 1456w" sizes="100vw" loading="lazy"></picture><div></div></div></a></figure></div><p>The <code>Safelist.basic</code> configuration permits a small set of formatting tags and links while dropping scripts and unsafe attributes. Projects that favor Jsoup as a general HTML parsing library often use this style of helper side by side with Markdown parsing to keep browser output under control.</p><h3>Conclusion</h3><p>The whole Markdown to HTML pipeline in this Spring Boot API comes down to a few stages where the controller accepts Markdown text, a service hands that text to CommonMark or Flexmark to build HTML, and a sanitizer such as the OWASP Java HTML Sanitizer or Jsoup removes unsafe pieces before anything reaches the browser. Keeping HTTP handling, Markdown parsing, and HTML sanitization in their own places makes the code easier to reason about and lets a blog engine, note app, or CMS plug the API in wherever rendered HTML is needed.</p><ol><li><p><em><a href="https://docs.spring.io/spring-boot/index.html">Spring Boot Reference Documentation</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-framework/reference/">Spring Web MVC Documentation</a></em></p></li><li><p><em><a href="https://github.com/commonmark/commonmark-java">CommonMark Java Project</a></em></p></li><li><p><em><a href="https://github.com/vsch/flexmark-java">Flexmark Java Project</a></em></p></li><li><p><em><a href="https://owasp.org/www-project-java-html-sanitizer/">OWASP Java HTML Sanitizer</a></em></p></li><li><p><em><a href="https://jsoup.org/apidocs/">Jsoup HTML Parser Javadoc</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Xr3E!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Xr3E!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!Xr3E!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!Xr3E!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!Xr3E!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Xr3E!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Xr3E!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!Xr3E!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!Xr3E!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!Xr3E!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7134d812-46cc-4a95-8d96-79e2c02a3f51_276x276.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://icons8.com/icon/90519/spring-boot">Spring Boot</a> icon by <a href="https://icons8.com/">Icons8</a></figcaption></figure></div>]]></content:encoded></item><item><title><![CDATA[MQTT Gateway Backed by Spring Boot for IoT Messages]]></title><description><![CDATA[It&#8217;s common for IoT projects to rely on MQTT brokers to move device readings, commands, and status updates between small devices and backend services.]]></description><link>https://alexanderobregon.substack.com/p/mqtt-gateway-backed-by-spring-boot</link><guid isPermaLink="false">https://alexanderobregon.substack.com/p/mqtt-gateway-backed-by-spring-boot</guid><dc:creator><![CDATA[Alexander Obregon]]></dc:creator><pubDate>Mon, 16 Feb 2026 18:07:17 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/0850e84d-e2f6-47f2-8965-2a48fb085f43_480x480.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ns2F!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ns2F!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!ns2F!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!ns2F!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!ns2F!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ns2F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg" width="800" height="444" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:444,&quot;width&quot;:800,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ns2F!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg 424w, https://substackcdn.com/image/fetch/$s_!ns2F!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg 848w, https://substackcdn.com/image/fetch/$s_!ns2F!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg 1272w, https://substackcdn.com/image/fetch/$s_!ns2F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F07f8173b-0115-40a1-bb77-f1065776f2a8_800x444.jpeg 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://spring.io/projects/spring-boot">Image Source</a></figcaption></figure></div><p>It&#8217;s common for IoT projects to rely on MQTT brokers to move device readings, commands, and status updates between small devices and backend services. Instead of talking directly to the broker from every client, a Spring Boot gateway can sit beside it as a bridge that takes MQTT messages from devices and normalizes them into well defined objects. The gateway then serves that data over HTTP or streaming transports like WebSockets or RSocket, so constrained devices keep their lightweight MQTT link while web clients and backend systems talk to the gateway through familiar web protocols.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/subscribe?"><span>Subscribe now</span></a></p><h3>MQTT Gateway Mechanics In Spring Boot</h3><p>In a Spring Boot gateway, that service runs as an MQTT client, subscribes to device topics, receives raw payloads from the broker, and forwards them into the Spring messaging infrastructure. From there, normalizer components can turn byte arrays into typed objects, attach metadata, and hand those objects to downstream layers that eventually provide HTTP or streaming access.</p><p>Spring Integration gives this gateway a consistent way to bring MQTT traffic into Spring. The <code>spring-integration-mqtt</code> module wraps the <code>Eclipse Paho</code> client library and exposes inbound and outbound channel adapters, so the gateway code works with Spring <code>Message</code> objects and channels instead of talking directly to the MQTT client API all the time. This keeps the connection details, subscriptions, and message acknowledgments together in configuration classes while other parts of the code focus on parsing, validation, and storage.</p><h4>Message Flow from Device to Broker To Gateway</h4><p>From the device side, a tiny MQTT client publishes readings to a topic such as <code>devices/eau-claire-sensor-01/state</code> on a broker like Mosquitto, EMQX, or HiveMQ. The broker stores the session, handles retained messages if configured, and forwards each publish packet to any subscribed clients. One of those clients is the Spring Boot gateway, which connects with its own client identifier, subscribes to one or more topic filters, and receives MQTT packets that carry payload bytes and headers.</p><p>Topic filters such as <code>devices/+/state</code> use MQTT wildcards so the gateway does not need a separate subscription per device. A plus symbol in the filter matches exactly one level between slashes, which means <code>devices/+/state</code> matches <code>devices/eau-claire-sensor-01/state</code> and <code>devices/wisconsin-sensor-02/state</code>, while <code>devices/#</code> matches deeper paths as well. The gateway can combine these filters with Quality of Service levels to balance delivery guarantees against bandwidth and storage needs for a given deployment.</p><p>With Spring Integration, a configuration class defines the MQTT connection factory, the inbound adapter, and the channel where messages land. The configuration below uses <code>DefaultMqttPahoClientFactory</code> and an inbound adapter that subscribes to all device state messages:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!AloZ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!AloZ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png 424w, https://substackcdn.com/image/fetch/$s_!AloZ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png 848w, https://substackcdn.com/image/fetch/$s_!AloZ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png 1272w, https://substackcdn.com/image/fetch/$s_!AloZ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!AloZ!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png" width="1128" height="618.2307692307693" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:798,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1128,&quot;bytes&quot;:187467,&quot;alt&quot;:&quot;@Configuration @EnableIntegration public class MqttConfig {      @Bean     public MqttPahoClientFactory mqttClientFactory() {         DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();          MqttConnectOptions options = new MqttConnectOptions();         options.setServerURIs(new String[] { \&quot;tcp://mqtt-broker.example.com:1883\&quot; });         options.setUserName(\&quot;gateway-service\&quot;);         options.setPassword(\&quot;s3cr3t\&quot;.toCharArray());          factory.setConnectionOptions(options);         return factory;     }      @Bean     public MessageChannel mqttInputChannel() {         return new DirectChannel();     }      @Bean     public MessageProducer mqttInbound(MqttPahoClientFactory factory) {         MqttPahoMessageDrivenChannelAdapter adapter =                 new MqttPahoMessageDrivenChannelAdapter(                         \&quot;spring-gateway-client\&quot;,                         factory,                         \&quot;devices/+/state\&quot;                 );          DefaultPahoMessageConverter converter = new DefaultPahoMessageConverter();         converter.setPayloadAsBytes(true);         adapter.setConverter(converter);          adapter.setQos(1);         adapter.setOutputChannel(mqttInputChannel());         return adapter;     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="@Configuration @EnableIntegration public class MqttConfig {      @Bean     public MqttPahoClientFactory mqttClientFactory() {         DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();          MqttConnectOptions options = new MqttConnectOptions();         options.setServerURIs(new String[] { &quot;tcp://mqtt-broker.example.com:1883&quot; });         options.setUserName(&quot;gateway-service&quot;);         options.setPassword(&quot;s3cr3t&quot;.toCharArray());          factory.setConnectionOptions(options);         return factory;     }      @Bean     public MessageChannel mqttInputChannel() {         return new DirectChannel();     }      @Bean     public MessageProducer mqttInbound(MqttPahoClientFactory factory) {         MqttPahoMessageDrivenChannelAdapter adapter =                 new MqttPahoMessageDrivenChannelAdapter(                         &quot;spring-gateway-client&quot;,                         factory,                         &quot;devices/+/state&quot;                 );          DefaultPahoMessageConverter converter = new DefaultPahoMessageConverter();         converter.setPayloadAsBytes(true);         adapter.setConverter(converter);          adapter.setQos(1);         adapter.setOutputChannel(mqttInputChannel());         return adapter;     } }" title="@Configuration @EnableIntegration public class MqttConfig {      @Bean     public MqttPahoClientFactory mqttClientFactory() {         DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();          MqttConnectOptions options = new MqttConnectOptions();         options.setServerURIs(new String[] { &quot;tcp://mqtt-broker.example.com:1883&quot; });         options.setUserName(&quot;gateway-service&quot;);         options.setPassword(&quot;s3cr3t&quot;.toCharArray());          factory.setConnectionOptions(options);         return factory;     }      @Bean     public MessageChannel mqttInputChannel() {         return new DirectChannel();     }      @Bean     public MessageProducer mqttInbound(MqttPahoClientFactory factory) {         MqttPahoMessageDrivenChannelAdapter adapter =                 new MqttPahoMessageDrivenChannelAdapter(                         &quot;spring-gateway-client&quot;,                         factory,                         &quot;devices/+/state&quot;                 );          DefaultPahoMessageConverter converter = new DefaultPahoMessageConverter();         converter.setPayloadAsBytes(true);         adapter.setConverter(converter);          adapter.setQos(1);         adapter.setOutputChannel(mqttInputChannel());         return adapter;     } }" srcset="https://substackcdn.com/image/fetch/$s_!AloZ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png 424w, https://substackcdn.com/image/fetch/$s_!AloZ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png 848w, https://substackcdn.com/image/fetch/$s_!AloZ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png 1272w, https://substackcdn.com/image/fetch/$s_!AloZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64359b66-0f0c-488e-846e-0d1ec6623627_1791x981.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>This configuration treats <code>mqttInputChannel</code> as the handoff point between the MQTT adapter and the rest of the application. The adapter handles MQTT connection management, subscriptions, and message receipt. To keep the payload as raw bytes for the normalizer, configure the inbound adapter with a <code>DefaultPahoMessageConverter</code> and set <code>payloadAsBytes</code> to <code>true</code>. Anything connected to <code>mqttInputChannel</code> then receives <code>Message&lt;byte[]&gt;</code> instances with headers like <code>mqtt_receivedTopic</code>, plus related headers like <code>mqtt_receivedQos</code> and <code>mqtt_receivedRetained</code>.</p><p>Downstream, a normalizer component can subscribe to that channel and convert raw MQTT payloads into device reading objects that make sense to other services. A handler with <code>@ServiceActivator</code> works well for this stage:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!DLzg!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!DLzg!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png 424w, https://substackcdn.com/image/fetch/$s_!DLzg!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png 848w, https://substackcdn.com/image/fetch/$s_!DLzg!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png 1272w, https://substackcdn.com/image/fetch/$s_!DLzg!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!DLzg!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png" width="1456" height="677" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:677,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:194105,&quot;alt&quot;:&quot;@Service public class DeviceMessageHandler {      private final DeviceReadingRepository repository;      public DeviceMessageHandler(DeviceReadingRepository repository) {         this.repository = repository;     }      @ServiceActivator(inputChannel = \&quot;mqttInputChannel\&quot;)     public void handle(Message<byte[]> mqttMessage) {         String topic = mqttMessage.getHeaders()                                   .get(\&quot;mqtt_receivedTopic\&quot;, String.class);         byte[] payload = mqttMessage.getPayload();          DeviceReading reading = DeviceReading.from(topic, payload);         repository.save(reading);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@Service public class DeviceMessageHandler {      private final DeviceReadingRepository repository;      public DeviceMessageHandler(DeviceReadingRepository repository) {         this.repository = repository;     }      @ServiceActivator(inputChannel = &quot;mqttInputChannel&quot;)     public void handle(Message<byte[]> mqttMessage) {         String topic = mqttMessage.getHeaders()                                   .get(&quot;mqtt_receivedTopic&quot;, String.class);         byte[] payload = mqttMessage.getPayload();          DeviceReading reading = DeviceReading.from(topic, payload);         repository.save(reading);     } }" title="@Service public class DeviceMessageHandler {      private final DeviceReadingRepository repository;      public DeviceMessageHandler(DeviceReadingRepository repository) {         this.repository = repository;     }      @ServiceActivator(inputChannel = &quot;mqttInputChannel&quot;)     public void handle(Message<byte[]> mqttMessage) {         String topic = mqttMessage.getHeaders()                                   .get(&quot;mqtt_receivedTopic&quot;, String.class);         byte[] payload = mqttMessage.getPayload();          DeviceReading reading = DeviceReading.from(topic, payload);         repository.save(reading);     } }" srcset="https://substackcdn.com/image/fetch/$s_!DLzg!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png 424w, https://substackcdn.com/image/fetch/$s_!DLzg!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png 848w, https://substackcdn.com/image/fetch/$s_!DLzg!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png 1272w, https://substackcdn.com/image/fetch/$s_!DLzg!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9b3a4e85-90ea-42a0-879a-a9a782419219_1728x803.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>DeviceReading can parse JSON or another encoding, pull out sensor values, and attach the device identifier derived from the topic, so the rest of the code works with a stable domain object rather than raw byte arrays. That same object later feeds REST controllers or streaming endpoints, but the translation work stays in one place near the MQTT edge.</p><p>Some projects prefer to start with a direct MQTT client before moving to Spring Integration. For a basic connection with Eclipse Paho v3 inside a Spring managed bean, is can look like this:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!d5RP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!d5RP!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png 424w, https://substackcdn.com/image/fetch/$s_!d5RP!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png 848w, https://substackcdn.com/image/fetch/$s_!d5RP!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png 1272w, https://substackcdn.com/image/fetch/$s_!d5RP!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!d5RP!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png" width="1036" height="554.2884615384615" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:779,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1036,&quot;bytes&quot;:199214,&quot;alt&quot;:&quot;@Component public class BasicMqttClient implements MqttCallback {      private final MqttClient client;      public BasicMqttClient() throws MqttException {         this.client = new MqttClient(                 \&quot;tcp://mqtt-broker.example.com:1883\&quot;,                 \&quot;basic-gateway-client\&quot;         );         MqttConnectOptions options = new MqttConnectOptions();         options.setUserName(\&quot;gateway-service\&quot;);         options.setPassword(\&quot;s3cr3t\&quot;.toCharArray());         client.setCallback(this);         client.connect(options);         client.subscribe(\&quot;devices/+/state\&quot;, 1);     }      @Override     public void messageArrived(String topic, MqttMessage message) throws Exception {         byte[] payload = message.getPayload();         // pass bytes into a normalizer component     }      @Override     public void connectionLost(Throwable cause) {         // reconnect logic can go here     }      @Override     public void deliveryComplete(IMqttDeliveryToken token) {         // unused for inbound only use case     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="@Component public class BasicMqttClient implements MqttCallback {      private final MqttClient client;      public BasicMqttClient() throws MqttException {         this.client = new MqttClient(                 &quot;tcp://mqtt-broker.example.com:1883&quot;,                 &quot;basic-gateway-client&quot;         );         MqttConnectOptions options = new MqttConnectOptions();         options.setUserName(&quot;gateway-service&quot;);         options.setPassword(&quot;s3cr3t&quot;.toCharArray());         client.setCallback(this);         client.connect(options);         client.subscribe(&quot;devices/+/state&quot;, 1);     }      @Override     public void messageArrived(String topic, MqttMessage message) throws Exception {         byte[] payload = message.getPayload();         // pass bytes into a normalizer component     }      @Override     public void connectionLost(Throwable cause) {         // reconnect logic can go here     }      @Override     public void deliveryComplete(IMqttDeliveryToken token) {         // unused for inbound only use case     } }" title="@Component public class BasicMqttClient implements MqttCallback {      private final MqttClient client;      public BasicMqttClient() throws MqttException {         this.client = new MqttClient(                 &quot;tcp://mqtt-broker.example.com:1883&quot;,                 &quot;basic-gateway-client&quot;         );         MqttConnectOptions options = new MqttConnectOptions();         options.setUserName(&quot;gateway-service&quot;);         options.setPassword(&quot;s3cr3t&quot;.toCharArray());         client.setCallback(this);         client.connect(options);         client.subscribe(&quot;devices/+/state&quot;, 1);     }      @Override     public void messageArrived(String topic, MqttMessage message) throws Exception {         byte[] payload = message.getPayload();         // pass bytes into a normalizer component     }      @Override     public void connectionLost(Throwable cause) {         // reconnect logic can go here     }      @Override     public void deliveryComplete(IMqttDeliveryToken token) {         // unused for inbound only use case     } }" srcset="https://substackcdn.com/image/fetch/$s_!d5RP!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png 424w, https://substackcdn.com/image/fetch/$s_!d5RP!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png 848w, https://substackcdn.com/image/fetch/$s_!d5RP!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png 1272w, https://substackcdn.com/image/fetch/$s_!d5RP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7fecf15b-49b5-44bd-a634-c852e0ed0e68_1784x955.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>This style of client keeps MQTT related concerns in a single component, while other Spring beans receive decoded readings through method calls or messaging channels. Spring Integration then becomes an option when the project needs more routing, transformation, and monitoring features with less custom glue code around the client.</p><h4>MQTT Version Choices 3.1.1 vs 5.0</h4><p>Device fleets that talk to a broker today usually use MQTT 3.1.1 or MQTT 5.0, and a gateway has to match what the broker and devices support. Version 3.1.1 remains common in existing deployments, while 5.0 brings message properties, richer reason codes, shared subscriptions, and more detailed error reports. Both versions keep the publish and subscribe model, QoS levels, retain flags, and topic filters, but MQTT 5.0 adds structured data that helps operations and routing.</p><p>Message properties in MQTT 5.0 include user properties, correlation data, and response topics. User properties are name value pairs attached to the publish packet, and they travel with the message through the broker. Gateways can treat user properties as metadata that holds device firmware versions, tenant identifiers, or building tags, then carry those values into HTTP headers, database columns, or Spring message headers without touching the payload body.</p><p>Spring Integration adds separate adapters for MQTT v3 and MQTT v5, both inside the <code>spring-integration-mqtt</code> module. The v3 adapter uses <code>MqttPahoMessageDrivenChannelAdapter</code>, while the v5 adapter uses <code>Mqttv5PahoMessageDrivenChannelAdapter</code>, and the inbound <code>MqttHeaderMapper</code> maps MQTT <code>PUBLISH</code> properties, including user properties, into Spring message headers. That mapping keeps MQTT specific details near the edge of the system while letting internal components work with plain Spring messages that already have all needed metadata attached.</p><p>Now, let&#8217;s see how an MQTT 5 inbound adapter can be set up in a Spring Boot gateway:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!5T2F!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!5T2F!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png 424w, https://substackcdn.com/image/fetch/$s_!5T2F!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png 848w, https://substackcdn.com/image/fetch/$s_!5T2F!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png 1272w, https://substackcdn.com/image/fetch/$s_!5T2F!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!5T2F!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png" width="862" height="465.3379120879121" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:786,&quot;width&quot;:1456,&quot;resizeWidth&quot;:862,&quot;bytes&quot;:212139,&quot;alt&quot;:&quot;@Configuration @EnableIntegration public class MqttV5Config {      @Bean     public MessageChannel mqttV5InputChannel() {         return new DirectChannel();     }      @Bean     public MessageProducer mqttV5Inbound() {         MqttConnectionOptions options = new MqttConnectionOptions();         options.setServerURIs(new String[] { \&quot;tcp://mqtt-broker.example.com:1883\&quot; });         options.setUserName(\&quot;gateway-service-v5\&quot;);         options.setPassword(\&quot;v5-secret\&quot;.getBytes());          Mqttv5PahoMessageDrivenChannelAdapter adapter =                 new Mqttv5PahoMessageDrivenChannelAdapter(                         options,                         \&quot;spring-gateway-client-v5\&quot;,                         \&quot;devices/+/events\&quot;                 );          adapter.setQos(1);         adapter.setOutputChannel(mqttV5InputChannel());         return adapter;     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="@Configuration @EnableIntegration public class MqttV5Config {      @Bean     public MessageChannel mqttV5InputChannel() {         return new DirectChannel();     }      @Bean     public MessageProducer mqttV5Inbound() {         MqttConnectionOptions options = new MqttConnectionOptions();         options.setServerURIs(new String[] { &quot;tcp://mqtt-broker.example.com:1883&quot; });         options.setUserName(&quot;gateway-service-v5&quot;);         options.setPassword(&quot;v5-secret&quot;.getBytes());          Mqttv5PahoMessageDrivenChannelAdapter adapter =                 new Mqttv5PahoMessageDrivenChannelAdapter(                         options,                         &quot;spring-gateway-client-v5&quot;,                         &quot;devices/+/events&quot;                 );          adapter.setQos(1);         adapter.setOutputChannel(mqttV5InputChannel());         return adapter;     } }" title="@Configuration @EnableIntegration public class MqttV5Config {      @Bean     public MessageChannel mqttV5InputChannel() {         return new DirectChannel();     }      @Bean     public MessageProducer mqttV5Inbound() {         MqttConnectionOptions options = new MqttConnectionOptions();         options.setServerURIs(new String[] { &quot;tcp://mqtt-broker.example.com:1883&quot; });         options.setUserName(&quot;gateway-service-v5&quot;);         options.setPassword(&quot;v5-secret&quot;.getBytes());          Mqttv5PahoMessageDrivenChannelAdapter adapter =                 new Mqttv5PahoMessageDrivenChannelAdapter(                         options,                         &quot;spring-gateway-client-v5&quot;,                         &quot;devices/+/events&quot;                 );          adapter.setQos(1);         adapter.setOutputChannel(mqttV5InputChannel());         return adapter;     } }" srcset="https://substackcdn.com/image/fetch/$s_!5T2F!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png 424w, https://substackcdn.com/image/fetch/$s_!5T2F!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png 848w, https://substackcdn.com/image/fetch/$s_!5T2F!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png 1272w, https://substackcdn.com/image/fetch/$s_!5T2F!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F13e5457c-dc5a-4f75-96ce-a60a7d2218dd_1759x950.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Gateways that subscribe to <code>devices/+/events</code> in this way can process <code>MQTT 5</code> packets and still pass <code>byte[]</code> payloads into the normalizer layer, while <code>MQTT 5 PUBLISH</code> properties, including <code>user properties</code>, are mapped into message headers through a <code>HeaderMapper&lt;MqttProperties&gt;</code>. Downstream handlers can read those headers and store or forward the values without parsing the payload again.</p><p>Not every gateway project uses Spring Integration for MQTT connectivity. Some prefer to interact with MQTT clients directly, in which case the choice of library matters. Eclipse Paho v3, provided by the <code>org.eclipse.paho.client.mqttv3</code> artifact, speaks MQTT 3.1.1 and remains in wide use on brokers that have not moved to v5. For MQTT 5.0, Eclipse Paho offers <code>org.eclipse.paho.mqttv5.client</code>, and the HiveMQ MQTT Client gives a single API that can talk to both protocol versions with blocking, asynchronous, and reactive styles.</p><p>Code that uses the HiveMQ MQTT Client to connect with MQTT 5.0 and attach user properties to an outgoing message can be as simple as:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!0ajY!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!0ajY!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png 424w, https://substackcdn.com/image/fetch/$s_!0ajY!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png 848w, https://substackcdn.com/image/fetch/$s_!0ajY!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png 1272w, https://substackcdn.com/image/fetch/$s_!0ajY!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!0ajY!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png" width="1074" height="557.6538461538462" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;large&quot;,&quot;height&quot;:756,&quot;width&quot;:1456,&quot;resizeWidth&quot;:1074,&quot;bytes&quot;:166080,&quot;alt&quot;:&quot;public class HiveMqttExample {      private final Mqtt5AsyncClient client;      public HiveMqttExample() {         this.client = MqttClient.builder()                 .useMqttVersion5()                 .identifier(\&quot;spring-gateway-hivemq\&quot;)                 .serverHost(\&quot;mqtt-broker.example.com\&quot;)                 .serverPort(1883)                 .buildAsync();     }      public void connectAndPublish() {         client.connectWith()               .simpleAuth()               .username(\&quot;gateway-service\&quot;)               .password(\&quot;hivemq-secret\&quot;.getBytes())               .applySimpleAuth()               .send()               .join();          client.publishWith()               .topic(\&quot;devices/eau-claire-sensor-01/state\&quot;)               .payload(\&quot;{\\\&quot;temperature\\\&quot;: 21.5}\&quot;.getBytes())               .userProperties()                   .add(\&quot;firmware\&quot;, \&quot;1.0.3\&quot;)                   .add(\&quot;site\&quot;, \&quot;Eau Claire\&quot;)                   .applyUserProperties()               .send()               .join();     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:&quot;center&quot;,&quot;offset&quot;:false}" class="sizing-large" alt="public class HiveMqttExample {      private final Mqtt5AsyncClient client;      public HiveMqttExample() {         this.client = MqttClient.builder()                 .useMqttVersion5()                 .identifier(&quot;spring-gateway-hivemq&quot;)                 .serverHost(&quot;mqtt-broker.example.com&quot;)                 .serverPort(1883)                 .buildAsync();     }      public void connectAndPublish() {         client.connectWith()               .simpleAuth()               .username(&quot;gateway-service&quot;)               .password(&quot;hivemq-secret&quot;.getBytes())               .applySimpleAuth()               .send()               .join();          client.publishWith()               .topic(&quot;devices/eau-claire-sensor-01/state&quot;)               .payload(&quot;{\&quot;temperature\&quot;: 21.5}&quot;.getBytes())               .userProperties()                   .add(&quot;firmware&quot;, &quot;1.0.3&quot;)                   .add(&quot;site&quot;, &quot;Eau Claire&quot;)                   .applyUserProperties()               .send()               .join();     } }" title="public class HiveMqttExample {      private final Mqtt5AsyncClient client;      public HiveMqttExample() {         this.client = MqttClient.builder()                 .useMqttVersion5()                 .identifier(&quot;spring-gateway-hivemq&quot;)                 .serverHost(&quot;mqtt-broker.example.com&quot;)                 .serverPort(1883)                 .buildAsync();     }      public void connectAndPublish() {         client.connectWith()               .simpleAuth()               .username(&quot;gateway-service&quot;)               .password(&quot;hivemq-secret&quot;.getBytes())               .applySimpleAuth()               .send()               .join();          client.publishWith()               .topic(&quot;devices/eau-claire-sensor-01/state&quot;)               .payload(&quot;{\&quot;temperature\&quot;: 21.5}&quot;.getBytes())               .userProperties()                   .add(&quot;firmware&quot;, &quot;1.0.3&quot;)                   .add(&quot;site&quot;, &quot;Eau Claire&quot;)                   .applyUserProperties()               .send()               .join();     } }" srcset="https://substackcdn.com/image/fetch/$s_!0ajY!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png 424w, https://substackcdn.com/image/fetch/$s_!0ajY!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png 848w, https://substackcdn.com/image/fetch/$s_!0ajY!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png 1272w, https://substackcdn.com/image/fetch/$s_!0ajY!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fddbfa612-b89f-417f-9160-d3023e175b1d_1788x928.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Gateways do not need to publish readings themselves if devices already send data, yet this style of code is useful when the Spring service has to push commands back to the broker while keeping track of metadata such as firmware and site for auditing or debugging.</p><p>When choosing between MQTT 3.1.1 and 5.0 for a gateway, the decision usually comes down to the broker and devices that already exist and the amount of metadata the system expects to carry in headers. Legacy hardware that only understands MQTT 3.1.1 keeps the gateway on that version, while new projects that want user properties, shared subscriptions, and richer diagnostics tend to align with MQTT 5.0. Spring Integration and modern client libraries make it possible to support both versions in a single codebase, with dedicated client beans per broker or device group.</p><h3>Gateway Outputs for REST WebSocket or RSocket Clients</h3><p>After MQTT messages pass through the gateway&#8217;s normalization layer, the service holds device data in a form that Java code can work with easily, such as domain objects backed by a repository or a cache. From that point, the main question turns into how different clients reach that data. Some callers only want a quick snapshot over HTTP, some browser dashboards want a continuous feed without reconnecting, and some backend services need long running streams with backpressure so they do not get flooded. Spring Boot supports all three styles inside one application. REST controllers handle request and response traffic, WebSocket endpoints carry push updates into browsers, and RSocket routes reactive streams between services that share a more advanced protocol. The MQTT gateway ends up as a hub that speaks MQTT on one side and a mix of HTTP, WebSocket, and RSocket on the other, while the normalization layer keeps the data model consistent across all three paths.</p><h4>REST Endpoints for Normalized Snapshots</h4><p>REST fits callers that think in terms of request and response cycles. A mobile app, a scheduled job, or a configuration panel may only need the latest device state or a small window of recent readings, and can tolerate polling every few seconds or minutes. The gateway can expose controllers that read from the normalized store and return JSON payloads without any awareness of MQTT topics or QoS flags.</p><p>One common starting point is a Spring MVC controller that fetches the most recent state for a single device. The code can lean on a service that hides persistence details:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!wjmo!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!wjmo!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png 424w, https://substackcdn.com/image/fetch/$s_!wjmo!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png 848w, https://substackcdn.com/image/fetch/$s_!wjmo!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png 1272w, https://substackcdn.com/image/fetch/$s_!wjmo!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!wjmo!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png" width="1456" height="573" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:573,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:161330,&quot;alt&quot;:&quot;@RestController @RequestMapping(\&quot;/api/device-state\&quot;) public class DeviceStateController {      private final DeviceStateService deviceStateService;      public DeviceStateController(DeviceStateService deviceStateService) {         this.deviceStateService = deviceStateService;     }      @GetMapping(\&quot;/{deviceId}\&quot;)     public DeviceStateDto getLatestState(@PathVariable String deviceId) {         DeviceState state = deviceStateService.loadLatestState(deviceId);         return DeviceStateDto.from(state);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@RestController @RequestMapping(&quot;/api/device-state&quot;) public class DeviceStateController {      private final DeviceStateService deviceStateService;      public DeviceStateController(DeviceStateService deviceStateService) {         this.deviceStateService = deviceStateService;     }      @GetMapping(&quot;/{deviceId}&quot;)     public DeviceStateDto getLatestState(@PathVariable String deviceId) {         DeviceState state = deviceStateService.loadLatestState(deviceId);         return DeviceStateDto.from(state);     } }" title="@RestController @RequestMapping(&quot;/api/device-state&quot;) public class DeviceStateController {      private final DeviceStateService deviceStateService;      public DeviceStateController(DeviceStateService deviceStateService) {         this.deviceStateService = deviceStateService;     }      @GetMapping(&quot;/{deviceId}&quot;)     public DeviceStateDto getLatestState(@PathVariable String deviceId) {         DeviceState state = deviceStateService.loadLatestState(deviceId);         return DeviceStateDto.from(state);     } }" srcset="https://substackcdn.com/image/fetch/$s_!wjmo!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png 424w, https://substackcdn.com/image/fetch/$s_!wjmo!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png 848w, https://substackcdn.com/image/fetch/$s_!wjmo!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png 1272w, https://substackcdn.com/image/fetch/$s_!wjmo!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0fbb1fee-747b-4656-b56f-85a29a640d7f_1720x677.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>This controller suits clients that occasionally ask for one device&#8217;s status and then disconnect again, while the service behind <code>loadLatestState</code> talks to a repository that was filled by the MQTT normalizer.</p><p>Gateway endpoints sometimes also expose short history ranges so clients can draw charts or run basic analytics. For example this next method returns a collection of readings for the last hour:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!3_pu!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!3_pu!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png 424w, https://substackcdn.com/image/fetch/$s_!3_pu!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png 848w, https://substackcdn.com/image/fetch/$s_!3_pu!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png 1272w, https://substackcdn.com/image/fetch/$s_!3_pu!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!3_pu!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png" width="1456" height="463" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:463,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:134083,&quot;alt&quot;:&quot;@GetMapping(\&quot;/{deviceId}/history\&quot;) public List<DeviceStateDto> getRecentHistory(         @PathVariable String deviceId,         @RequestParam(defaultValue = \&quot;60\&quot;) int minutes) {      Instant cutoff = Instant.now().minus(Duration.ofMinutes(minutes));     List<DeviceState> states =             deviceStateService.loadStatesSince(deviceId, cutoff);      return states.stream()             .map(DeviceStateDto::from)             .toList(); }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@GetMapping(&quot;/{deviceId}/history&quot;) public List<DeviceStateDto> getRecentHistory(         @PathVariable String deviceId,         @RequestParam(defaultValue = &quot;60&quot;) int minutes) {      Instant cutoff = Instant.now().minus(Duration.ofMinutes(minutes));     List<DeviceState> states =             deviceStateService.loadStatesSince(deviceId, cutoff);      return states.stream()             .map(DeviceStateDto::from)             .toList(); }" title="@GetMapping(&quot;/{deviceId}/history&quot;) public List<DeviceStateDto> getRecentHistory(         @PathVariable String deviceId,         @RequestParam(defaultValue = &quot;60&quot;) int minutes) {      Instant cutoff = Instant.now().minus(Duration.ofMinutes(minutes));     List<DeviceState> states =             deviceStateService.loadStatesSince(deviceId, cutoff);      return states.stream()             .map(DeviceStateDto::from)             .toList(); }" srcset="https://substackcdn.com/image/fetch/$s_!3_pu!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png 424w, https://substackcdn.com/image/fetch/$s_!3_pu!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png 848w, https://substackcdn.com/image/fetch/$s_!3_pu!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png 1272w, https://substackcdn.com/image/fetch/$s_!3_pu!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc5ee2a3c-9389-4c34-9b7b-1a16facbd887_1725x548.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>That method gives callers a simple query knob through the <code>minutes</code> parameter, while the gateway still shields them from MQTT topics, client identifiers, and any protocol specific details that the devices use.</p><p>Some projects adopt Spring WebFlux when they expect a larger number of concurrent HTTP clients or want tighter alignment with reactive repositories. With WebFlux, the same idea shows up through <code>Mono</code> and <code>Flux</code> return types instead of blocking calls:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!kov1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!kov1!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png 424w, https://substackcdn.com/image/fetch/$s_!kov1!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png 848w, https://substackcdn.com/image/fetch/$s_!kov1!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png 1272w, https://substackcdn.com/image/fetch/$s_!kov1!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!kov1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png" width="1456" height="636" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:636,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:173635,&quot;alt&quot;:&quot;@RestController @RequestMapping(\&quot;/api/reactive-device-state\&quot;) public class ReactiveDeviceStateController {      private final ReactiveDeviceStateService reactiveService;      public ReactiveDeviceStateController(ReactiveDeviceStateService reactiveService) {         this.reactiveService = reactiveService;     }      @GetMapping(\&quot;/{deviceId}\&quot;)     public Mono<DeviceStateDto> getLatestStateReactive(             @PathVariable String deviceId) {          return reactiveService.loadLatestState(deviceId)                 .map(DeviceStateDto::from);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@RestController @RequestMapping(&quot;/api/reactive-device-state&quot;) public class ReactiveDeviceStateController {      private final ReactiveDeviceStateService reactiveService;      public ReactiveDeviceStateController(ReactiveDeviceStateService reactiveService) {         this.reactiveService = reactiveService;     }      @GetMapping(&quot;/{deviceId}&quot;)     public Mono<DeviceStateDto> getLatestStateReactive(             @PathVariable String deviceId) {          return reactiveService.loadLatestState(deviceId)                 .map(DeviceStateDto::from);     } }" title="@RestController @RequestMapping(&quot;/api/reactive-device-state&quot;) public class ReactiveDeviceStateController {      private final ReactiveDeviceStateService reactiveService;      public ReactiveDeviceStateController(ReactiveDeviceStateService reactiveService) {         this.reactiveService = reactiveService;     }      @GetMapping(&quot;/{deviceId}&quot;)     public Mono<DeviceStateDto> getLatestStateReactive(             @PathVariable String deviceId) {          return reactiveService.loadLatestState(deviceId)                 .map(DeviceStateDto::from);     } }" srcset="https://substackcdn.com/image/fetch/$s_!kov1!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png 424w, https://substackcdn.com/image/fetch/$s_!kov1!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png 848w, https://substackcdn.com/image/fetch/$s_!kov1!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png 1272w, https://substackcdn.com/image/fetch/$s_!kov1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe38550fd-472b-40cc-bf88-b33aab87aa9e_1731x756.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Reactive controllers fit well when the MQTT normalization pipeline already works with <code>Flux</code> and <code>Mono</code>, because the same reactive stream can feed persistence and HTTP endpoints without extra thread handoffs.</p><h4>WebSocket Streams for Browsers</h4><p>Browser dashboards usually benefit from a push model where the server sends new readings as soon as they arrive, instead of waiting for clients to poll. WebSockets give a full duplex connection between browser and server over a single HTTP upgrade, and Spring&#8217;s STOMP over WebSocket support fits that need without forcing front end code to deal with binary frames directly.</p><p>In a gateway, the first step is to configure the message broker for WebSocket traffic and register a STOMP endpoint for the browser to connect to:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!IPNH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!IPNH!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png 424w, https://substackcdn.com/image/fetch/$s_!IPNH!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png 848w, https://substackcdn.com/image/fetch/$s_!IPNH!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png 1272w, https://substackcdn.com/image/fetch/$s_!IPNH!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!IPNH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png" width="1456" height="571" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:571,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:177613,&quot;alt&quot;:&quot;@Configuration @EnableWebSocketMessageBroker public class GatewayWebSocketConfig implements WebSocketMessageBrokerConfigurer {      @Override     public void configureMessageBroker(MessageBrokerRegistry registry) {         registry.enableSimpleBroker(\&quot;/topic/device-stream\&quot;);         registry.setApplicationDestinationPrefixes(\&quot;/app\&quot;);     }      @Override     public void registerStompEndpoints(StompEndpointRegistry registry) {         registry.addEndpoint(\&quot;/ws/device-stream\&quot;)                 .setAllowedOriginPatterns(\&quot;*\&quot;);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@Configuration @EnableWebSocketMessageBroker public class GatewayWebSocketConfig implements WebSocketMessageBrokerConfigurer {      @Override     public void configureMessageBroker(MessageBrokerRegistry registry) {         registry.enableSimpleBroker(&quot;/topic/device-stream&quot;);         registry.setApplicationDestinationPrefixes(&quot;/app&quot;);     }      @Override     public void registerStompEndpoints(StompEndpointRegistry registry) {         registry.addEndpoint(&quot;/ws/device-stream&quot;)                 .setAllowedOriginPatterns(&quot;*&quot;);     } }" title="@Configuration @EnableWebSocketMessageBroker public class GatewayWebSocketConfig implements WebSocketMessageBrokerConfigurer {      @Override     public void configureMessageBroker(MessageBrokerRegistry registry) {         registry.enableSimpleBroker(&quot;/topic/device-stream&quot;);         registry.setApplicationDestinationPrefixes(&quot;/app&quot;);     }      @Override     public void registerStompEndpoints(StompEndpointRegistry registry) {         registry.addEndpoint(&quot;/ws/device-stream&quot;)                 .setAllowedOriginPatterns(&quot;*&quot;);     } }" srcset="https://substackcdn.com/image/fetch/$s_!IPNH!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png 424w, https://substackcdn.com/image/fetch/$s_!IPNH!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png 848w, https://substackcdn.com/image/fetch/$s_!IPNH!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png 1272w, https://substackcdn.com/image/fetch/$s_!IPNH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2db02f0d-eae6-40c1-913c-77e3723b1be0_1717x673.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Front end code can connect to <code>/ws/device-stream</code> and subscribe to destinations that make sense for a dashboard, such as <code>/topic/device-stream/eau-claire-sensor-01</code>. The gateway then needs a bridge that takes normalized readings from the MQTT side and forwards them to those destinations.</p><p>One way to handle that bridge is to have the MQTT normalizer publish domain objects onto a Spring channel like <code>deviceUpdateChannel</code>, and then use a dedicated service to forward those objects through <code>SimpMessagingTemplate</code>:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Nt8n!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Nt8n!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png 424w, https://substackcdn.com/image/fetch/$s_!Nt8n!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png 848w, https://substackcdn.com/image/fetch/$s_!Nt8n!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png 1272w, https://substackcdn.com/image/fetch/$s_!Nt8n!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Nt8n!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png" width="1456" height="572" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:572,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:170966,&quot;alt&quot;:&quot;@Service public class DeviceUpdateBroadcaster {      private final SimpMessagingTemplate messagingTemplate;      public DeviceUpdateBroadcaster(SimpMessagingTemplate messagingTemplate) {         this.messagingTemplate = messagingTemplate;     }      @ServiceActivator(inputChannel = \&quot;deviceUpdateChannel\&quot;)     public void broadcast(DeviceState state) {         String destination = \&quot;/topic/device-stream/\&quot; + state.deviceId();         DeviceStateDto dto = DeviceStateDto.from(state);         messagingTemplate.convertAndSend(destination, dto);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@Service public class DeviceUpdateBroadcaster {      private final SimpMessagingTemplate messagingTemplate;      public DeviceUpdateBroadcaster(SimpMessagingTemplate messagingTemplate) {         this.messagingTemplate = messagingTemplate;     }      @ServiceActivator(inputChannel = &quot;deviceUpdateChannel&quot;)     public void broadcast(DeviceState state) {         String destination = &quot;/topic/device-stream/&quot; + state.deviceId();         DeviceStateDto dto = DeviceStateDto.from(state);         messagingTemplate.convertAndSend(destination, dto);     } }" title="@Service public class DeviceUpdateBroadcaster {      private final SimpMessagingTemplate messagingTemplate;      public DeviceUpdateBroadcaster(SimpMessagingTemplate messagingTemplate) {         this.messagingTemplate = messagingTemplate;     }      @ServiceActivator(inputChannel = &quot;deviceUpdateChannel&quot;)     public void broadcast(DeviceState state) {         String destination = &quot;/topic/device-stream/&quot; + state.deviceId();         DeviceStateDto dto = DeviceStateDto.from(state);         messagingTemplate.convertAndSend(destination, dto);     } }" srcset="https://substackcdn.com/image/fetch/$s_!Nt8n!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png 424w, https://substackcdn.com/image/fetch/$s_!Nt8n!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png 848w, https://substackcdn.com/image/fetch/$s_!Nt8n!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png 1272w, https://substackcdn.com/image/fetch/$s_!Nt8n!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4e1f93ad-10b8-4b0d-a3cc-9195dd05bd60_1723x677.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Browser subscribers that point to the same destination see a JSON representation of <code>DeviceStateDto</code> every time the MQTT pipeline produces a new state update for that device.</p><p>Browsers sometimes also send commands back toward devices, such as toggling relays or requesting configuration changes. STOMP message mappings can handle those inbound messages on the WebSocket side, and the gateway then publishes corresponding MQTT messages to reach devices. The controller that handles those commands can look like this:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!g_BY!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!g_BY!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png 424w, https://substackcdn.com/image/fetch/$s_!g_BY!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png 848w, https://substackcdn.com/image/fetch/$s_!g_BY!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png 1272w, https://substackcdn.com/image/fetch/$s_!g_BY!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!g_BY!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png" width="1456" height="496" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:496,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:122352,&quot;alt&quot;:&quot;@Controller public class DeviceCommandController {      private final DeviceCommandService commandService;      public DeviceCommandController(DeviceCommandService commandService) {         this.commandService = commandService;     }      @MessageMapping(\&quot;/device-command\&quot;)     public void handleCommand(DeviceCommandDto command) {         commandService.sendCommandToDevice(command);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@Controller public class DeviceCommandController {      private final DeviceCommandService commandService;      public DeviceCommandController(DeviceCommandService commandService) {         this.commandService = commandService;     }      @MessageMapping(&quot;/device-command&quot;)     public void handleCommand(DeviceCommandDto command) {         commandService.sendCommandToDevice(command);     } }" title="@Controller public class DeviceCommandController {      private final DeviceCommandService commandService;      public DeviceCommandController(DeviceCommandService commandService) {         this.commandService = commandService;     }      @MessageMapping(&quot;/device-command&quot;)     public void handleCommand(DeviceCommandDto command) {         commandService.sendCommandToDevice(command);     } }" srcset="https://substackcdn.com/image/fetch/$s_!g_BY!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png 424w, https://substackcdn.com/image/fetch/$s_!g_BY!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png 848w, https://substackcdn.com/image/fetch/$s_!g_BY!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png 1272w, https://substackcdn.com/image/fetch/$s_!g_BY!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3ae668c1-7ad6-4427-9922-2be4d3b8ae5a_1729x589.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Command handling brings the gateway full circle, with MQTT traffic carrying both telemetry and control messages, while browsers only ever talk through STOMP frames over WebSocket.</p><h4>RSocket Streams for Backend Services</h4><p>Backend services that consume device data in bulk often need more than occasional snapshots or browser friendly feeds. RSocket addresses that by providing a binary protocol with support for request and response, request and stream, fire and forget, and bidirectional channels, all built around reactive streams semantics and backpressure. Spring Boot integrates RSocket so a gateway can expose its normalized device streams directly to other JVM services or polyglot clients that speak the protocol.</p><p>RSocket server support in Spring Boot usually lives in configuration or in properties. When the gateway runs as a separate RSocket server on a TCP port, the port and mapping are controlled through application configuration, while controllers declare message mappings for RSocket routes. Let&#8217;s see how a controller that streams device readings to a consumer service can look:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!GDwO!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!GDwO!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png 424w, https://substackcdn.com/image/fetch/$s_!GDwO!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png 848w, https://substackcdn.com/image/fetch/$s_!GDwO!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png 1272w, https://substackcdn.com/image/fetch/$s_!GDwO!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!GDwO!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png" width="1456" height="536" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:536,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:144946,&quot;alt&quot;:&quot;@Controller public class DeviceStreamController {      private final DeviceStreamService deviceStreamService;      public DeviceStreamController(DeviceStreamService deviceStreamService) {         this.deviceStreamService = deviceStreamService;     }      @MessageMapping(\&quot;devices.stream.by-id\&quot;)     public Flux<DeviceStateDto> streamByDeviceId(String deviceId) {         return deviceStreamService.streamStates(deviceId)                 .map(DeviceStateDto::from);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@Controller public class DeviceStreamController {      private final DeviceStreamService deviceStreamService;      public DeviceStreamController(DeviceStreamService deviceStreamService) {         this.deviceStreamService = deviceStreamService;     }      @MessageMapping(&quot;devices.stream.by-id&quot;)     public Flux<DeviceStateDto> streamByDeviceId(String deviceId) {         return deviceStreamService.streamStates(deviceId)                 .map(DeviceStateDto::from);     } }" title="@Controller public class DeviceStreamController {      private final DeviceStreamService deviceStreamService;      public DeviceStreamController(DeviceStreamService deviceStreamService) {         this.deviceStreamService = deviceStreamService;     }      @MessageMapping(&quot;devices.stream.by-id&quot;)     public Flux<DeviceStateDto> streamByDeviceId(String deviceId) {         return deviceStreamService.streamStates(deviceId)                 .map(DeviceStateDto::from);     } }" srcset="https://substackcdn.com/image/fetch/$s_!GDwO!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png 424w, https://substackcdn.com/image/fetch/$s_!GDwO!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png 848w, https://substackcdn.com/image/fetch/$s_!GDwO!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png 1272w, https://substackcdn.com/image/fetch/$s_!GDwO!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe427d8f7-b144-4625-a0ae-1ca41131c86a_1720x633.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Client services that connect over RSocket can subscribe to the <code>devices.stream.by-id</code> route and receive a continuous <code>Flux</code> of device state updates, while backpressure requests from the client control how fast new elements are delivered.</p><p>Some gateways also need an RSocket path for aggregated streams that combine MQTT data from many devices. In that case, the backing service can accept filters as part of the request and route them into the reactive pipeline:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!5HZX!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!5HZX!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png 424w, https://substackcdn.com/image/fetch/$s_!5HZX!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png 848w, https://substackcdn.com/image/fetch/$s_!5HZX!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png 1272w, https://substackcdn.com/image/fetch/$s_!5HZX!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!5HZX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png" width="1456" height="531" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:531,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:154649,&quot;alt&quot;:&quot;@Controller public class AggregatedStreamController {      private final AggregatedStreamService aggregatedStreamService;      public AggregatedStreamController(AggregatedStreamService aggregatedStreamService) {         this.aggregatedStreamService = aggregatedStreamService;     }      @MessageMapping(\&quot;devices.stream.site\&quot;)     public Flux<DeviceStateDto> streamBySite(DeviceSiteFilter filter) {         return aggregatedStreamService.streamBySite(filter.siteId())                 .map(DeviceStateDto::from);     } }&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://alexanderobregon.substack.com/i/187577446?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="@Controller public class AggregatedStreamController {      private final AggregatedStreamService aggregatedStreamService;      public AggregatedStreamController(AggregatedStreamService aggregatedStreamService) {         this.aggregatedStreamService = aggregatedStreamService;     }      @MessageMapping(&quot;devices.stream.site&quot;)     public Flux<DeviceStateDto> streamBySite(DeviceSiteFilter filter) {         return aggregatedStreamService.streamBySite(filter.siteId())                 .map(DeviceStateDto::from);     } }" title="@Controller public class AggregatedStreamController {      private final AggregatedStreamService aggregatedStreamService;      public AggregatedStreamController(AggregatedStreamService aggregatedStreamService) {         this.aggregatedStreamService = aggregatedStreamService;     }      @MessageMapping(&quot;devices.stream.site&quot;)     public Flux<DeviceStateDto> streamBySite(DeviceSiteFilter filter) {         return aggregatedStreamService.streamBySite(filter.siteId())                 .map(DeviceStateDto::from);     } }" srcset="https://substackcdn.com/image/fetch/$s_!5HZX!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png 424w, https://substackcdn.com/image/fetch/$s_!5HZX!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png 848w, https://substackcdn.com/image/fetch/$s_!5HZX!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png 1272w, https://substackcdn.com/image/fetch/$s_!5HZX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb7c77c9a-ac96-4330-9775-fb7ad52c65e2_1735x633.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Gateway code behind <code>streamBySite</code> can use Reactor operators to filter, group, or window MQTT derived readings before pushing them to downstream consumers, while RSocket keeps backpressure signals flowing so slow clients do not cause unbounded queues inside the process.</p><h3>Conclusion</h3><p>Spring Boot MQTT gateways bring device traffic, brokers, and clients into one coherent flow built from a small set of responsibilities. The service connects as an MQTT client, subscribes to device topics over MQTT 3.1.1 or 5.0, and turns byte payloads plus message properties into normalized domain objects. Those objects move through Spring messaging into REST controllers, WebSocket broadcasts, or RSocket streams, so HTTP callers, browsers, and backend services all see the same structure while devices keep a lightweight MQTT link. With transport, normalization, and delivery held in separate layers, developers can adjust how clients reach the data without touching device firmware or broker behavior.</p><ol><li><p><em><a href="https://docs.spring.io/spring-boot/docs/current/reference/html/">Spring Boot Reference Documentation</a></em></p></li><li><p><em><a href="https://docs.spring.io/spring-integration/reference/mqtt.html">Spring Integration MQTT Module Docs</a></em></p></li><li><p><em><a href="https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html">MQTT Version 5 OASIS Specification</a></em></p></li><li><p><em><a href="https://eclipse.dev/paho/clients/java/">Eclipse Paho Java Client Docs</a></em></p></li><li><p><em><a href="https://github.com/rsocket/rsocket-java">RSocket Java</a></em></p></li></ol><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share&quot;,&quot;text&quot;:&quot;Share Alexander Obregon's Substack&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://alexanderobregon.substack.com/?utm_source=substack&utm_medium=email&utm_content=share&action=share"><span>Share Alexander Obregon's Substack</span></a></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!u-h7!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!u-h7!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!u-h7!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!u-h7!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!u-h7!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!u-h7!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png" width="276" height="276" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:276,&quot;width&quot;:276,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!u-h7!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png 424w, https://substackcdn.com/image/fetch/$s_!u-h7!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png 848w, https://substackcdn.com/image/fetch/$s_!u-h7!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png 1272w, https://substackcdn.com/image/fetch/$s_!u-h7!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd91a482b-ffba-46ff-b7a6-1f71d327a766_276x276.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption"><a href="https://icons8.com/icon/90519/spring-boot">Spring Boot</a> icon by <a href="https://icons8.com/">Icons8</a></figcaption></figure></div>]]></content:encoded></item></channel></rss>