<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Nuno Silva</title>
    <description>The latest articles on Forem by Nuno Silva (@nunosilva).</description>
    <link>https://forem.com/nunosilva</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3766457%2F711c7020-0587-4e3c-aa45-0c6e5587af7e.jpg</url>
      <title>Forem: Nuno Silva</title>
      <link>https://forem.com/nunosilva</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/nunosilva"/>
    <language>en</language>
    <item>
      <title>The Pokémon Pattern - Gotta catch 'em all</title>
      <dc:creator>Nuno Silva</dc:creator>
      <pubDate>Fri, 20 Mar 2026 17:14:21 +0000</pubDate>
      <link>https://forem.com/nunosilva/the-pokemon-pattern-gotta-catch-em-all-4oj7</link>
      <guid>https://forem.com/nunosilva/the-pokemon-pattern-gotta-catch-em-all-4oj7</guid>
      <description>&lt;p&gt;Picture this. It is late on a Friday afternoon. You are integrating an external API — a third-party service your application depends on. You know the call might fail, but you are not sure exactly what exceptions the client library throws, and digging through their documentation is a rabbit hole you don't have time for right now.&lt;/p&gt;

&lt;p&gt;So you take the shortcut that lives in every codebase. You wrap the call in a &lt;code&gt;try&lt;/code&gt;, write &lt;code&gt;catch (Exception e)&lt;/code&gt;, log the error, return &lt;code&gt;false&lt;/code&gt;, and move on. The PR is approved. The app doesn't crash. Everyone goes home.&lt;/p&gt;

&lt;p&gt;What nobody realises — not you, not the reviewer, not the team — is that you just introduced a silent killer into the codebase.&lt;/p&gt;

&lt;p&gt;Not because the pattern is lazy. But because it is &lt;em&gt;plausible&lt;/em&gt;. It looks like defensive programming. It feels like resilience. It will pass code review, pass QA, and pass every test you throw at it — right up until the moment a half-executed database transaction quietly corrupts your data, and nobody can figure out why because the logs just say &lt;code&gt;"Something went wrong"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the Pokémon Pattern. &lt;code&gt;catch (Exception e)&lt;/code&gt;. Gotta catch 'em all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Illusion of Resilience
&lt;/h2&gt;

&lt;p&gt;The core mistake of the Pokémon Pattern is that it treats two fundamentally different categories of problems as if they were the same thing.&lt;/p&gt;

&lt;p&gt;The first category is &lt;strong&gt;expected business failures&lt;/strong&gt;. The user typed the wrong password. The account has insufficient funds. The item is out of stock. These are not bugs. They are normal, anticipated branches of your application's logic — outcomes the business has already thought about and has rules for handling.&lt;/p&gt;

&lt;p&gt;The second category is &lt;strong&gt;system panics&lt;/strong&gt;. The database connection died mid-transaction. A third-party API returned malformed JSON. A &lt;code&gt;NullPointerException&lt;/code&gt; was thrown halfway through processing an order. These are not business outcomes. They are the application telling you something has gone structurally wrong.&lt;/p&gt;

&lt;p&gt;When we write &lt;code&gt;catch (Exception e)&lt;/code&gt;, we throw a blanket over both. Here is what that blanket looks like in practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;checkoutService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;processOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Something went wrong"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks safe. But consider what actually happens when &lt;code&gt;processOrder&lt;/code&gt; throws a &lt;code&gt;NullPointerException&lt;/code&gt; halfway through — say, after deducting inventory but before recording the transaction.&lt;/p&gt;

&lt;p&gt;If this method is annotated with &lt;code&gt;@Transactional&lt;/code&gt;, the behaviour is particularly insidious. By swallowing the exception, we signal to the framework that the method completed successfully. Spring sees no exception, so it commits. The partial state — inventory reduced, transaction unrecorded — is now permanently written to the database. There is no rollback. There is no error. There is just quietly corrupted data, and a log file that says &lt;code&gt;"Something went wrong"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We didn't engineer resilience. We engineered a zombie application. It is dead, but it is still walking around.&lt;/p&gt;




&lt;h2&gt;
  
  
  Better Logs Don't Fix Bad Architecture
&lt;/h2&gt;

&lt;p&gt;When developers realise their logs are full of useless generic errors, the instinct is usually to write better messages inside the &lt;code&gt;catch&lt;/code&gt; block. Add more context. Log the order ID. Log the user. Make the string more descriptive.&lt;/p&gt;

&lt;p&gt;But a better string does not stop data corruption. It just makes the corruption easier to read about after the fact.&lt;/p&gt;

&lt;p&gt;The real problem is not the log message. It is that the &lt;code&gt;catch&lt;/code&gt; block is in the wrong place, doing the wrong job, for the wrong reason. No amount of string interpolation fixes that.&lt;/p&gt;

&lt;p&gt;There is a better architecture — and it starts with drawing a hard line between a failure and a panic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handle Failures, Let Panics Crash
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Return Failures — Don't Throw Them
&lt;/h3&gt;

&lt;p&gt;An out-of-stock item is not an exceptional circumstance. It is a standard, predictable branch of business logic. Using the &lt;code&gt;throw&lt;/code&gt; keyword to handle it is reaching for the wrong tool.&lt;/p&gt;

&lt;p&gt;When we throw a business exception, we create an invisible &lt;code&gt;GOTO&lt;/code&gt; statement inside our own codebase. The method signature promises nothing about what might happen. Callers have to guess — or read the implementation — or hope the documentation is accurate.&lt;/p&gt;

&lt;p&gt;The fix is to make the failure explicit in the method signature using a &lt;code&gt;Result&lt;/code&gt; type. Java doesn't have one natively, but a custom wrapper or sealed interfaces achieve the same effect — the compiler forces the caller to handle the failure rather than ignore it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: the signature lies — it secretly throws ItemUnavailableException&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;OrderConfirmation&lt;/span&gt; &lt;span class="nf"&gt;submitOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Cart&lt;/span&gt; &lt;span class="n"&gt;cart&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After: the signature is honest — the compiler forces the caller to handle it&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderConfirmation&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;submitOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Cart&lt;/span&gt; &lt;span class="n"&gt;cart&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When failures are returned as values rather than thrown as exceptions, they become part of the contract. The caller cannot ignore them. The &lt;code&gt;try/catch&lt;/code&gt; block disappears from the domain logic entirely — not because we removed it, but because there is nothing left to catch.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Let Panics Crash
&lt;/h3&gt;

&lt;p&gt;If the database goes offline, or a variable is unexpectedly null, &lt;code&gt;checkoutService&lt;/code&gt; has no idea how to recover from that — and it should not try. Attempting to catch and absorb a panic does not resolve it. It just allows the application to execute more code on top of a broken foundation.&lt;/p&gt;

&lt;p&gt;Let the thread crash. Let the panic bubble up immediately, before it has a chance to touch another line of business logic. A fast, loud, localised failure is always preferable to a slow, silent, system-wide one.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Catch Panics at the Boundary — Nowhere Else
&lt;/h3&gt;

&lt;p&gt;Letting panics bubble up does not mean users see raw stack traces. It means we catch them in exactly one place: the outer edge of the application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The only place catch (Exception e) belongs: the absolute boundary&lt;/span&gt;
&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleGlobalPanic&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;pagerDutyService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;triggerAlarm&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"An unexpected error occurred"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INTERNAL_SERVER_ERROR&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This boundary catcher does three things well. It catches every unhandled panic in one predictable location. It alerts the on-call engineer immediately, with the full stack trace intact. And it returns a &lt;code&gt;500&lt;/code&gt; to the caller — which is the honest and correct response. Something did go wrong on the server, and the caller deserves to know that. What we avoid is returning a cheerful &lt;code&gt;200&lt;/code&gt; with a hidden error payload, which would be the HTTP equivalent of the Pokémon Pattern itself.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;catch (Exception e)&lt;/code&gt; pattern is not banned. It is &lt;em&gt;relocated&lt;/em&gt; — from scattered throughout the domain logic to a single, honest, explicitly-purposed boundary.&lt;/p&gt;




&lt;p&gt;A &lt;code&gt;try/catch&lt;/code&gt; block is not a band-aid. It is a highly specific control flow tool — and like any tool, its value depends entirely on using it in the right place, for the right job.&lt;/p&gt;

&lt;p&gt;When we catch generic exceptions to keep the application alive, we are making a trade we rarely intend: the illusion of uptime in exchange for the integrity of our data. We are hiding the exact stack traces we will desperately need when something goes wrong. We are teaching the system to lie about its own health.&lt;/p&gt;

&lt;p&gt;Exceptions are for exceptional circumstances. Business rules are for business logic. Drawing a hard line between the two is not a theoretical nicety — it is what makes the difference between a system that fails loudly and honestly, and one that silently corrupts your database at 3am on a Saturday.&lt;/p&gt;

</description>
      <category>java</category>
      <category>architecture</category>
      <category>cleancode</category>
      <category>exceptionhandling</category>
    </item>
    <item>
      <title>Precision Data Access in Spring Data JPA: A Guide to Projections</title>
      <dc:creator>Nuno Silva</dc:creator>
      <pubDate>Fri, 20 Feb 2026 12:22:25 +0000</pubDate>
      <link>https://forem.com/nunosilva/precision-data-access-in-spring-data-jpa-a-guide-to-projections-5449</link>
      <guid>https://forem.com/nunosilva/precision-data-access-in-spring-data-jpa-a-guide-to-projections-5449</guid>
      <description>&lt;p&gt;As an application matures, its domain model inevitably grows heavier. What started as a simple &lt;code&gt;Order&lt;/code&gt; entity evolves into a dense, interconnected graph of &lt;code&gt;LineItem&lt;/code&gt;, &lt;code&gt;CustomerProfile&lt;/code&gt;, &lt;code&gt;PaymentHistory&lt;/code&gt;, and &lt;code&gt;ShippingManifest&lt;/code&gt; objects. That complexity is necessary — your core business logic genuinely needs it. But it creates a hidden tax on every read operation in your system.&lt;/p&gt;

&lt;p&gt;The problem isn't the entity itself. The problem is using the same heavy entity fetch for every use case, regardless of what the caller actually needs.&lt;/p&gt;

&lt;p&gt;Consider an &lt;code&gt;Order&lt;/code&gt; entity with a dozen relationships. An invoice generation process needs all of it: the full entity graph, all lazy-loaded associations, the complete picture. A status monitoring job sitting next to it needs two fields: &lt;code&gt;orderId&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt;. If both use &lt;code&gt;findById()&lt;/code&gt; or &lt;code&gt;findAll()&lt;/code&gt;, the monitoring job is doing the exact same work as the invoice process — hydrating a full entity graph, triggering Hibernate's dirty-tracking machinery, and risking N+1 fetches on relationships it never touches.&lt;/p&gt;

&lt;p&gt;Spring Data JPA Projections solve this directly. They let you define exactly what data a caller needs and have the repository return precisely that — nothing more. This guide covers the projection types available in Spring Data JPA, when each is the right fit, and where each one breaks down.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem Projections Solve
&lt;/h2&gt;

&lt;p&gt;Before looking at the solutions, it's worth being precise about what's actually expensive when you fetch a full entity unnecessarily.&lt;/p&gt;

&lt;p&gt;When Hibernate loads a managed entity, it does more than execute a SELECT. It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Registers the entity in the first-level cache&lt;/strong&gt;, holding a reference for the duration of the Session&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Takes a state snapshot for dirty checking&lt;/strong&gt;, so it can detect changes and generate targeted UPDATEs on flush&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Initialises proxy objects&lt;/strong&gt; for every lazy relationship declared on the entity, even ones the caller will never touch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That machinery is essential when you're going to modify the entity and persist changes. When you're only reading two fields and discarding the result, you're paying for infrastructure you don't use.&lt;/p&gt;

&lt;p&gt;There's also the SQL itself. A standard &lt;code&gt;findAll()&lt;/code&gt; on a complex entity selects every mapped column. The difference between a full entity fetch and a projection is not just what arrives in your JVM — it's what travels across the wire on every single row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Full entity fetch
DB ──► id, status, created_at, updated_at,
       customer_id, billing_addr, shipping_addr,
       currency, discount, tax_rate, subtotal,
       total, notes, internal_ref, ...           ──► JVM
       (28 columns the caller will never read)

// Projection fetch (OrderStatusSummary)
DB ──► id, status                                ──► JVM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Projections fix both problems. They scope the SQL to the columns you actually need, and because the result isn't a managed entity, Hibernate skips the lifecycle overhead entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Version Decision: One Rule
&lt;/h2&gt;

&lt;p&gt;Before covering the projection types, here's the rule stated plainly so you can skip to what applies to your stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Java 16+&lt;/strong&gt;: Use Records. They're stable, concise, and compiler-enforced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Java 11 or below&lt;/strong&gt;: Use class-based DTOs, with Lombok's &lt;code&gt;@Value&lt;/code&gt; if it's on your classpath.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Java 14–15&lt;/strong&gt;: Records exist behind &lt;code&gt;--enable-preview&lt;/code&gt;, but preview features carry compatibility risk. Treat your stack as Java 11 for production purposes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both approaches use the same JPQL constructor expression syntax and generate identical SQL. The difference is purely in how much boilerplate you write to define the projection type.&lt;/p&gt;




&lt;h2&gt;
  
  
  Projection Types at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Java Version&lt;/th&gt;
&lt;th&gt;Boilerplate&lt;/th&gt;
&lt;th&gt;Type Safety&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interface projection&lt;/td&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Compile-time&lt;/td&gt;
&lt;td&gt;Simple root-entity field subsets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class-based DTO&lt;/td&gt;
&lt;td&gt;Java 8+&lt;/td&gt;
&lt;td&gt;High (or Lombok)&lt;/td&gt;
&lt;td&gt;Runtime (JPQL string)&lt;/td&gt;
&lt;td&gt;Multi-table joins on Java 11 or below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Record projection&lt;/td&gt;
&lt;td&gt;Java 16+ (stable)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Runtime (JPQL string)&lt;/td&gt;
&lt;td&gt;Multi-table joins on Java 16+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic projection&lt;/td&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Consolidating multiple fetch shapes into one repository method&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  1. Interface Projections
&lt;/h2&gt;

&lt;p&gt;The simplest form of projection is an interface that declares getter methods for the fields you want. Spring Data JPA generates a proxy at runtime that maps the query result to your interface.&lt;/p&gt;

&lt;p&gt;Suppose your status monitoring job only needs the order ID and its current status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You use it directly as a return type in your repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring inspects the interface at startup, derives the required fields from the getter names, and generates a SQL query scoped to those columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What Spring actually generates — not SELECT *&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No joins fired speculatively. No unmapped columns transferred. No entity lifecycle initialised. For a monitoring job running against a table with millions of rows, the difference in data transferred and query execution time is measurable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The SQL Spring generates is derived directly from your getter names. If a getter name doesn't match a mapped field on the entity, Spring will silently return &lt;code&gt;null&lt;/code&gt; for that field rather than throwing an error. Any time a projection returns unexpected nulls, enable SQL logging and verify the generated query — the mismatch is usually obvious from the column list.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Where Interface Projections Break Down
&lt;/h3&gt;

&lt;p&gt;Interface projections work cleanly when the fields you need map directly to columns on the root entity. They get dangerous when you need data from related entities.&lt;/p&gt;

&lt;p&gt;You can traverse relationships using nested interfaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;CustomerSummary&lt;/span&gt; &lt;span class="nf"&gt;getCustomer&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// nested projection&lt;/span&gt;

    &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;CustomerSummary&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks clean, but it hides a serious trap. When you back this with a derived query method like &lt;code&gt;findByStatus()&lt;/code&gt;, Spring Data does &lt;strong&gt;not&lt;/strong&gt; generate a join. Instead, it fetches the root projection and then issues a separate &lt;code&gt;SELECT&lt;/code&gt; for every single row's related entity to populate the nested proxy — the exact N+1 problem this approach was supposed to avoid.&lt;/p&gt;

&lt;p&gt;If you need a nested interface projection, you must back it with an explicit &lt;code&gt;@EntityGraph&lt;/code&gt; or a &lt;code&gt;@Query&lt;/code&gt; with a &lt;code&gt;JOIN&lt;/code&gt;. The derived query and the entity graph version are not equivalent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Generates N+1 silently.&lt;/span&gt;
&lt;span class="c1"&gt;// Spring fetches orders, then fires one SELECT per row to load the customer.&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Forces a join — one query, no surprises.&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@EntityGraph&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributePaths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"customer"&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable SQL logging (&lt;code&gt;spring.jpa.show-sql=true&lt;/code&gt;) and verify the generated output any time you introduce a nested interface projection. If you see repeated identical SELECTs, the join isn't being applied.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Open Projection Trap
&lt;/h3&gt;

&lt;p&gt;Interface projections support SpEL expressions via &lt;code&gt;@Value&lt;/code&gt;, which lets you compute derived fields from the entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"#{target.customer.firstName + ' ' + target.customer.lastName}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getCustomerFullName&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks convenient but completely defeats the purpose of using a projection. To evaluate a SpEL expression, Spring must load the &lt;strong&gt;entire entity graph&lt;/strong&gt; into memory — including all lazy relationships — before computing the result. You get none of the column scoping or lifecycle overhead savings that make projections valuable.&lt;/p&gt;

&lt;p&gt;If you need a computed field, derive it in SQL instead using a dedicated DTO or Record with an explicit constructor expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A dedicated projection for this specific read shape&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;OrderCustomerSummary&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customerFullName&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
    SELECT new com.yourapp.dto.OrderCustomerSummary(
        o.id,
        CONCAT(c.firstName, ' ', c.lastName)
    )
    FROM Order o
    JOIN o.customer c
    WHERE o.status = :status
"""&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderCustomerSummary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findWithCustomerName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database computes the concatenation, only the two resulting values cross the wire, and there's no entity graph loaded anywhere.&lt;/p&gt;

&lt;p&gt;There's also a subtler issue: interface projections are backed by a dynamic proxy, which means every field access goes through a method dispatch rather than a direct field read. For most use cases this cost is negligible. For a batch job processing millions of rows in a tight loop, it's worth being aware of.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Class-Based DTO Projections
&lt;/h2&gt;

&lt;p&gt;Before Java Records existed, the standard approach was a plain class with a constructor matching the fields you wanted to project. This is the right choice for any Spring Boot 2.7.x application, and it remains fully supported across all modern Spring Boot releases if Records aren't an option.&lt;/p&gt;

&lt;p&gt;The pattern relies on JPQL constructor expressions. You write a regular class with a matching constructor, and JPQL maps the query result directly into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Constructor must match the field order in the JPQL SELECT clause exactly&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;OrderStatusSummary&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository uses a &lt;code&gt;@Query&lt;/code&gt; with a constructor expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT new com.yourapp.dto.OrderStatusSummary(o.id, o.status) FROM Order o WHERE o.status = :status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated SQL is scoped to the columns you declare, with no entity lifecycle overhead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is a plain Java object with no Hibernate proxy, no dirty tracking, and no connection to the Session. It behaves identically to a Record projection in terms of what Hibernate does — the only difference is the boilerplate you write to define it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Boilerplate Problem at Scale
&lt;/h3&gt;

&lt;p&gt;The weakness of class-based DTOs becomes apparent when your domain has many different projection shapes. Each one requires a separate class with a constructor, getters, and — if you need equality or debugging — &lt;code&gt;equals()&lt;/code&gt;, &lt;code&gt;hashCode()&lt;/code&gt;, and &lt;code&gt;toString()&lt;/code&gt;. On Java 11 and older, that's a meaningful amount of code to maintain.&lt;/p&gt;

&lt;p&gt;The common mitigation before Records was Lombok:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Value&lt;/span&gt;  &lt;span class="c1"&gt;// generates constructor, getters, equals, hashCode, toString — immutable by default&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@Value&lt;/code&gt; gives you a functionally immutable class with zero hand-written boilerplate, and it works on Java 8+. If your team is already using Lombok and you're not yet on Java 16, this is the practical equivalent of a Record projection.&lt;/p&gt;

&lt;p&gt;One important caveat: class-based DTOs require a fully-qualified class name in the JPQL constructor expression. If you rename or move the class, the &lt;code&gt;@Query&lt;/code&gt; annotation won't fail at compile time — it will fail at runtime when the JPQL is parsed. This is a known fragility of the constructor expression approach, and it applies equally to Records.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Java Record Projections
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If you're on Java 11 or below, skip this section — the class-based DTO approach above is the direct equivalent.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Record projections are the modern replacement for class-based DTOs. The projection shape is declared as a Record, which gives you an immutable data carrier with a canonical constructor, &lt;code&gt;equals()&lt;/code&gt;, &lt;code&gt;hashCode()&lt;/code&gt;, and &lt;code&gt;toString()&lt;/code&gt; generated by the compiler — no Lombok required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;OrderStatusSummary&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository usage is identical to the class-based approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT new com.yourapp.dto.OrderStatusSummary(o.id, o.status) FROM Order o WHERE o.status = :status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated SQL and Hibernate behaviour are the same. The advantage is purely in the declaration: a one-line Record replaces a full DTO class, and the compiler enforces immutability rather than relying on convention.&lt;/p&gt;

&lt;p&gt;Records also compose cleanly with Java Streams. Because a Record is a transparent data carrier with value-based equality, you can group, deduplicate, and compare projection results without implementing &lt;code&gt;equals()&lt;/code&gt; yourself — something class-based DTOs require explicit attention to get right.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Dynamic Projections
&lt;/h2&gt;

&lt;p&gt;If you have a single entity accessed by many different callers, each needing a different slice of data, you end up with either a proliferation of repository methods or the temptation to return the full entity everywhere and let each caller ignore what it doesn't need. Dynamic Projections offer a third option: one repository method that accepts the desired return type as a parameter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Class&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each caller passes the projection type it needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Invoice process: needs the full managed entity&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;fullOrder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Status monitor: needs only the lightweight summary&lt;/span&gt;
&lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderStatusSummary&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring inspects the &lt;code&gt;Class&amp;lt;T&amp;gt;&lt;/code&gt; argument at runtime and generates the appropriate query — full entity fetch for &lt;code&gt;Order.class&lt;/code&gt;, scoped column fetch for a projection interface or Record.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Dynamic Projections Break Down
&lt;/h3&gt;

&lt;p&gt;The tradeoff is type safety. Because the return type is generic, the compiler cannot verify at build time that a given &lt;code&gt;Class&amp;lt;T&amp;gt;&lt;/code&gt; argument is a valid projection for this entity. Passing an incompatible type compiles fine and fails at runtime. In a large codebase with many callers, that's a meaningful operational risk.&lt;/p&gt;

&lt;p&gt;Dynamic Projections are a reasonable fit when you have a small, stable set of well-known projection types and the convenience of a single method is genuinely valuable. When the set of projection types is large or evolving, explicit repository methods with named return types are safer — the compiler enforces correctness, and the method signatures serve as documentation.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Projections Are Not For
&lt;/h2&gt;

&lt;p&gt;Projections are a read-only tool. They give you a scoped view of data for retrieval; they have no path back to the persistence context for writes.&lt;/p&gt;

&lt;p&gt;If your caller needs to load an entity, modify it, and save it, use a standard entity fetch — that's exactly what Hibernate's dirty checking and transaction management are built for. The overhead that projections eliminate is only overhead when you're not using it. For writes, you need the full entity lifecycle.&lt;/p&gt;

&lt;p&gt;The mental model that ties this to the N+1 problem: use projections for the same category of operations where you'd otherwise reach for a native SQL query returning a DTO. When you only need data — no state changes, no lifecycle — projections let you stay in the JPA abstraction while still being precise about what you ask the database for.&lt;/p&gt;




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

&lt;p&gt;Projections don't replace entities — they complement them by giving you a precise, read-only view of your data without loading what you don't need.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use interface projections&lt;/strong&gt; when the fields you need map directly to the root entity and you want minimal boilerplate. Always back nested interface projections with &lt;code&gt;@EntityGraph&lt;/code&gt; or an explicit &lt;code&gt;@Query&lt;/code&gt; join — derived queries will silently generate N+1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never use SpEL &lt;code&gt;@Value&lt;/code&gt; expressions in interface projections.&lt;/strong&gt; They force a full entity load and eliminate every performance benefit projections provide. Push computed fields into SQL instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use class-based DTO projections&lt;/strong&gt; (with Lombok's &lt;code&gt;@Value&lt;/code&gt; if available) on Java 11 or below. This is the workhorse for Spring Boot 2.7.x applications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Record projections&lt;/strong&gt; on Java 16+. Same SQL, same behaviour — less boilerplate, compiler-enforced immutability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use dynamic projections&lt;/strong&gt; when consolidating multiple fetch patterns behind a single repository method, with the understanding that type safety is enforced at runtime, not compile time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't use projections for writes.&lt;/strong&gt; Any operation that modifies state and persists it should use the full managed entity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Apply This Today
&lt;/h3&gt;

&lt;p&gt;Open your APM tool — Datadog, New Relic, or whatever you're running in production — and filter for your highest-frequency read queries. Alternatively, turn on &lt;code&gt;spring.jpa.show-sql=true&lt;/code&gt; locally and hit your most heavily used GET endpoints.&lt;/p&gt;

&lt;p&gt;For each one, ask: is the repository method returning a full entity, and is the caller actually using all of it? If the answer is no, you have a projection candidate. Pick the heaviest offender, replace the entity return type with a scoped interface or DTO projection, and measure the query execution time and memory allocation before and after. The delta is usually immediate and significant.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>performance</category>
      <category>database</category>
    </item>
    <item>
      <title>The N+1 Problem in Spring Data JPA: A Practical Guide</title>
      <dc:creator>Nuno Silva</dc:creator>
      <pubDate>Thu, 19 Feb 2026 17:00:27 +0000</pubDate>
      <link>https://forem.com/nunosilva/the-n1-problem-in-spring-data-jpa-a-practical-guide-8ek</link>
      <guid>https://forem.com/nunosilva/the-n1-problem-in-spring-data-jpa-a-practical-guide-8ek</guid>
      <description>&lt;p&gt;Spring Data JPA solves a real problem. It lets you model your domain as an object graph and persist it to a relational store without hand-writing every SQL statement. For writes, this is largely a good trade. For reads, it can quietly destroy your application's performance in ways that are nearly invisible until you're already in production.&lt;/p&gt;

&lt;p&gt;This guide explains why, with a specific focus on the &lt;strong&gt;N+1 query problem&lt;/strong&gt;—the most common and costly consequence of naive JPA usage—and walks through the practical fixes available in the Spring ecosystem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Impedance Mismatch
&lt;/h2&gt;

&lt;p&gt;The core tension between Hibernate and your database comes down to how each paradigm navigates data.&lt;/p&gt;

&lt;p&gt;Object-oriented code thinks in graphs. An &lt;code&gt;Order&lt;/code&gt; holds a reference to a &lt;code&gt;Customer&lt;/code&gt;, which holds a collection of &lt;code&gt;Address&lt;/code&gt; objects. You traverse the graph by following pointers: &lt;code&gt;order.getCustomer().getBillingAddress()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Relational databases think in sets. Data lives in flat, normalized tables. You retrieve related data by joining those sets—a fundamentally different operation, executed in a single pass by the query planner.&lt;/p&gt;

&lt;p&gt;Hibernate's job is to bridge these paradigms. The problem is that this mapping is lossy. When you write code that ignores the underlying execution model, Hibernate generates SQL that is technically correct but operationally catastrophic.&lt;/p&gt;




&lt;h2&gt;
  
  
  The N+1 Problem
&lt;/h2&gt;

&lt;p&gt;Consider a realistic scenario: you're building an internal admin endpoint that returns a list of open support tickets, along with the name of the assigned agent and their current workload (total tickets assigned to them).&lt;/p&gt;

&lt;p&gt;Your entities look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Ticket&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@ManyToOne&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FetchType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LAZY&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@JoinColumn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"agent_id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="n"&gt;assignedAgent&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mappedBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"assignedAgent"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And your service layer looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TicketSummaryDTO&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getOpenTicketSummaries&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ticketRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OPEN"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAssignedAgent&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// lazy load #1&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TicketSummaryDTO&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSubject&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTickets&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// lazy load #2&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;})&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code is readable and structurally sensible. But with &lt;code&gt;FetchType.LAZY&lt;/code&gt; (the JPA default), here is what Hibernate actually executes against the database — assuming 200 open tickets assigned across 40 agents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- [Query 1] The initial fetch — the "1" in N+1.&lt;/span&gt;
&lt;span class="c1"&gt;-- Returns 200 rows. Hibernate now holds 200 Ticket proxies,&lt;/span&gt;
&lt;span class="c1"&gt;-- each with an uninitialized assignedAgent reference.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'OPEN'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;


&lt;span class="c1"&gt;-- [Queries 2–201] Hibernate fires one query per Ticket to resolve each assignedAgent proxy.&lt;/span&gt;
&lt;span class="c1"&gt;-- This happens inside the stream(), the moment agent.getName() is called.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- ticket 1  → resolves agent 12&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- ticket 2  → resolves agent 47&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- ticket 3  → resolves agent 12 AGAIN&lt;/span&gt;
                                     &lt;span class="c1"&gt;-- The 1st-level cache would prevent this only if agent 12&lt;/span&gt;
                                     &lt;span class="c1"&gt;-- was already fully loaded before this proxy was accessed.&lt;/span&gt;
                                     &lt;span class="c1"&gt;-- In a stream(), access order depends on the data,&lt;/span&gt;
                                     &lt;span class="c1"&gt;-- so duplicate fetches are common.&lt;/span&gt;
&lt;span class="c1"&gt;-- ... repeated for all 200 tickets&lt;/span&gt;


&lt;span class="c1"&gt;-- [Queries 202–241] For each unique agent encountered, Hibernate loads their entire&lt;/span&gt;
&lt;span class="c1"&gt;-- ticket collection to satisfy the .size() call — transferring records you will&lt;/span&gt;
&lt;span class="c1"&gt;-- immediately discard, just to get a count.&lt;/span&gt;
&lt;span class="c1"&gt;-- Hibernate's @LazyCollection(LazyCollectionOption.EXTRA) can replace this with a&lt;/span&gt;
&lt;span class="c1"&gt;-- clean SELECT COUNT(...), but writing the native query (Solution #3) is the&lt;/span&gt;
&lt;span class="c1"&gt;-- superior architectural choice when you're building a DTO anyway.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- loads every ticket for agent 12&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- loads every ticket for agent 47&lt;/span&gt;
&lt;span class="c1"&gt;-- ... repeated for all 40 unique agents&lt;/span&gt;


&lt;span class="c1"&gt;-- Grand total: 1 + 200 + 40 = 241 queries minimum.&lt;/span&gt;
&lt;span class="c1"&gt;-- In the worst case (no cache hits on agents): 1 + 200 + 200 = 401 queries.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why This Hurts More Than You Expect
&lt;/h3&gt;

&lt;p&gt;The database itself is rarely the bottleneck here. A primary-key lookup with a good index is sub-millisecond. &lt;strong&gt;The damage comes from the network round-trip on each query.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Assume a conservative 1ms round-trip between your app server and database—realistic for services in the same VPC:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Queries&lt;/th&gt;
&lt;th&gt;Network Overhead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Naive lazy loading&lt;/td&gt;
&lt;td&gt;~401&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~401ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With Session-level deduplication&lt;/td&gt;
&lt;td&gt;~241&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~241ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single optimized query&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That 400ms is pure blocking wait—your application thread is parked while TCP packets traverse the wire. At low traffic, this is survivable. Under load, with dozens of concurrent requests hitting the same endpoint, you exhaust your thread pool and your HikariCP connection pool simultaneously. What looked like a 400ms endpoint becomes a 4,000ms one under modest concurrency.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Lazy Loading Is the Right Default
&lt;/h2&gt;

&lt;p&gt;The N+1 example above involves bulk reads, which makes it tempting to conclude that lazy loading is always wrong. It isn't. Lazy loading was designed for exactly this scenario: a single-entity fetch with conditional business logic.&lt;/p&gt;

&lt;p&gt;Consider a ticket detail endpoint that checks whether the assigned agent is overloaded — but only if the ticket is high priority:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readOnly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;TicketDetailDTO&lt;/span&gt; &lt;span class="nf"&gt;getTicketDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Ticket&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ticketRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EntityNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Ticket not found"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPriority&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;Priority&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HIGH&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RESOLVED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Only reached for high-priority tickets&lt;/span&gt;
        &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAssignedAgent&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// 1 PK lookup&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;workload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTickets&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// 1 PK lookup&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workload&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;escalationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flag&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;mapToDTO&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For any ticket that isn't high priority, this executes exactly one query. The lazy loads inside the &lt;code&gt;if&lt;/code&gt; block never fire. For high-priority tickets, it executes three queries total: the ticket, the agent, and the agent's ticket collection — each a direct primary-key lookup against an indexed column, costing roughly 1ms each.&lt;/p&gt;

&lt;p&gt;This is not the N+1 problem. N+1 occurs when the same lazy load fires repeatedly inside a loop over a collection. Here, each query fires at most once per request. Three indexed PK lookups at ~3ms total is not a performance issue — it's the system working correctly. You don't need a &lt;code&gt;JOIN FETCH&lt;/code&gt; here. A heavy join on every single request would be a pessimisation, not an optimisation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;FetchType.EAGER&lt;/code&gt; Is Still the Wrong Reflex
&lt;/h3&gt;

&lt;p&gt;A developer unfamiliar with the problem might look at the two lazy loads and reach for &lt;code&gt;FetchType.EAGER&lt;/code&gt; to eliminate them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ManyToOne&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FetchType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;EAGER&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// ❌&lt;/span&gt;
&lt;span class="nd"&gt;@JoinColumn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"agent_id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="n"&gt;assignedAgent&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a global change to the entity. It doesn't just affect this endpoint — it forces a join on every call to &lt;code&gt;findById&lt;/code&gt;, &lt;code&gt;findAll&lt;/code&gt;, &lt;code&gt;findByStatus&lt;/code&gt;, and every other repository method in the application. Every ticket fetch now loads the agent and their entire ticket collection, regardless of whether the caller needs it. You've optimised for the rarest branch and penalised everything else.&lt;/p&gt;

&lt;p&gt;The correct mental model is this: &lt;strong&gt;&lt;code&gt;FetchType.LAZY&lt;/code&gt; is the right default for single-entity conditional access. &lt;code&gt;JOIN FETCH&lt;/code&gt; and batch loading are the right tools for collections and loops.&lt;/strong&gt; Let the lazy loads fire when they're cheap and conditional; reach for explicit fetch strategies only when you know you're operating at scale.&lt;/p&gt;

&lt;p&gt;One boundary condition worth knowing: lazy loads require an active Hibernate &lt;code&gt;Session&lt;/code&gt;. If a detached entity is passed across a layer boundary and a proxy is accessed outside the original &lt;code&gt;@Transactional&lt;/code&gt; context, Hibernate will throw a &lt;code&gt;LazyInitializationException&lt;/code&gt;. That exception is not a signal to add &lt;code&gt;EAGER&lt;/code&gt; — it's a signal that your transaction boundary is in the wrong place.&lt;/p&gt;




&lt;h2&gt;
  
  
  Solutions, In Order of Preference
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. JPQL JOIN FETCH
&lt;/h3&gt;

&lt;p&gt;If you know at query time that you'll need the relationship, tell Hibernate to fetch it in the initial query. The cleanest way to do this in Spring Data JPA is a &lt;code&gt;@Query&lt;/code&gt; annotation with &lt;code&gt;JOIN FETCH&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Repository&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;TicketRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT t FROM Ticket t JOIN FETCH t.assignedAgent WHERE t.status = :status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByStatusWithAgent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces a single SQL join:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'OPEN'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One round-trip.&lt;/strong&gt; The tradeoff is result set size: a join duplicates the agent's columns across every ticket row they're assigned to. For OLTP workloads with reasonable cardinality, this is almost always the right trade.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caveat:&lt;/strong&gt; If you add a second &lt;code&gt;JOIN FETCH&lt;/code&gt; on a collection in the same query (e.g., fetching both &lt;code&gt;assignedAgent&lt;/code&gt; and some other &lt;code&gt;@OneToMany&lt;/code&gt;), Hibernate will throw a &lt;code&gt;MultipleBagFetchException&lt;/code&gt;. You can work around this by converting &lt;code&gt;List&lt;/code&gt; to &lt;code&gt;Set&lt;/code&gt; on your collections, but be aware this changes equality semantics and can cause subtle bugs. When you need multiple collections, batch loading is usually the better fit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;@BatchSize&lt;/code&gt; and &lt;code&gt;@EntityGraph&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When joins create unacceptable result set inflation, batch loading is a better fit. Hibernate's &lt;code&gt;@BatchSize&lt;/code&gt; annotation tells it to replace individual &lt;code&gt;SELECT ... WHERE id = ?&lt;/code&gt; queries with &lt;code&gt;SELECT ... WHERE id IN (?, ?, ...)&lt;/code&gt; batches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mappedBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"assignedAgent"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@BatchSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of N queries, Hibernate issues &lt;code&gt;ceil(N / batchSize)&lt;/code&gt; queries. For 40 agents with a batch size of 50, that's &lt;strong&gt;1 query instead of 40&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Alternatively, &lt;code&gt;@EntityGraph&lt;/code&gt; lets you declare fetch behavior at the query site without modifying the entity itself—useful when different callers need different fetch strategies on the same entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Repository&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;TicketRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@EntityGraph&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributePaths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"assignedAgent"&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ticket&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findByStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates a left outer join under the hood, similar to &lt;code&gt;JOIN FETCH&lt;/code&gt;, but without requiring a custom JPQL query.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; For our specific DTO example, this only solves the first N+1 (Tickets → Agents). Calling &lt;code&gt;.size()&lt;/code&gt; on the agent's tickets will still trigger lazy queries unless you also include &lt;code&gt;"assignedAgent.tickets"&lt;/code&gt; in the graph — which risks a Cartesian product and likely defeats the purpose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3. Write the Query Yourself
&lt;/h3&gt;

&lt;p&gt;For read-heavy endpoints that aggregate data or return partial projections, bypass Hibernate's entity model entirely. You're building a response DTO—you don't need dirty tracking, optimistic locking, or a managed entity lifecycle. You're paying for all of that overhead and throwing it away.&lt;/p&gt;

&lt;p&gt;Spring Data JPA supports projecting directly into a DTO via constructor expressions in JPQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
    SELECT new com.yourapp.dto.TicketSummaryDTO(
        t.id,
        t.subject,
        a.name,
        COUNT(all_t.id)
    )
    FROM Ticket t
    JOIN t.assignedAgent a
    LEFT JOIN Ticket all_t ON all_t.assignedAgent = a
    WHERE t.status = :status
    GROUP BY t.id, t.subject, a.id, a.name
"""&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TicketSummaryDTO&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findOpenTicketSummaries&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or drop to &lt;code&gt;JdbcTemplate&lt;/code&gt; entirely for anything complex enough that JPQL becomes harder to read than SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Repository&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketQueryRepository&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;JdbcTemplate&lt;/span&gt; &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TicketSummaryDTO&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findOpenTicketSummaries&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""
            SELECT
                t.id,
                t.subject,
                a.name            AS agent_name,
                COUNT(all_t.id)   AS agent_workload
            FROM tickets t
            JOIN agents a ON t.agent_id = a.id
            LEFT JOIN tickets all_t ON all_t.agent_id = a.id
            WHERE t.status = 'OPEN'
            GROUP BY t.id, t.subject, a.id, a.name
        """&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rowNum&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TicketSummaryDTO&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLong&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"subject"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"agent_name"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"agent_workload"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pushes aggregation into the database where it belongs, transfers only the columns you need, and involves zero Hibernate machinery.&lt;/p&gt;




&lt;h2&gt;
  
  
  Diagnosing Your Own Codebase
&lt;/h2&gt;

&lt;p&gt;Enable Hibernate's SQL logging and exercise your endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# application.properties
&lt;/span&gt;&lt;span class="py"&gt;spring.jpa.show-sql&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;spring.jpa.properties.hibernate.format_sql&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;logging.level.org.hibernate.SQL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;DEBUG&lt;/span&gt;
&lt;span class="py"&gt;logging.level.org.hibernate.orm.jdbc.bind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;TRACE  # shows bind parameters&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for sequences of structurally identical queries differing only in a bind parameter value. That's the N+1 pattern. In a busy system it's immediately obvious—you'll see the same &lt;code&gt;SELECT&lt;/code&gt; repeated dozens or hundreds of times in a single request trace.&lt;/p&gt;

&lt;p&gt;For production diagnostics, &lt;code&gt;pg_stat_statements&lt;/code&gt; (Postgres) or the slow query log (MySQL) will surface high-call-count queries that look cheap individually but dominate aggregate database load. &lt;strong&gt;A query that takes 0.5ms but executes 50,000 times per minute is a far bigger problem than a slow query that runs once.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Use JPA vs. When to Write SQL
&lt;/h2&gt;

&lt;p&gt;The useful mental model isn't "Hibernate is bad"—it's that Hibernate has a domain where it excels and a domain where it actively works against you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use JPA for transactional writes.&lt;/strong&gt; Loading an entity, applying business logic, and persisting changes is exactly what Hibernate was designed for. It handles dirty checking, optimistic locking via &lt;code&gt;@Version&lt;/code&gt;, and transaction demarcation cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use SQL for reads that aggregate, project, or span multiple tables.&lt;/strong&gt; The object-graph abstraction is a poor fit for set-oriented retrieval. Forcing it results in either N+1 queries or increasingly complex fetch annotations that are just obfuscated SQL with more failure modes.&lt;/p&gt;

&lt;p&gt;This maps to a pattern the CQRS literature has formalized: your read model and your write model have different requirements. You don't need to adopt full CQRS to internalize that lesson. Even within a standard Spring layered application, being intentional about when you lean on JPA and when you reach for &lt;code&gt;JdbcTemplate&lt;/code&gt; will significantly improve both performance and maintainability.&lt;/p&gt;




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

&lt;p&gt;The N+1 problem isn't a Hibernate bug—it's a consequence of using an abstraction without understanding its execution model.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit read-heavy endpoints&lt;/strong&gt; with SQL logging before they reach production; treat N+1 as a build-breaking issue, not something to revisit later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;JOIN FETCH&lt;/code&gt; or &lt;code&gt;@EntityGraph&lt;/code&gt;&lt;/strong&gt; when you know a relationship will be traversed at query time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;@BatchSize&lt;/code&gt;&lt;/strong&gt; when joins produce excessive result set inflation or when fetching multiple collections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use JPQL constructor expressions or &lt;code&gt;JdbcTemplate&lt;/code&gt;&lt;/strong&gt; for aggregations and reporting queries—don't hydrate entities you're going to immediately project away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In production, watch query count alongside query duration;&lt;/strong&gt; a fast query executed 500 times per request is still a catastrophic query.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;The next layer of this problem is understanding what your database actually does with the SQL Hibernate generates. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; in Postgres—or &lt;code&gt;EXPLAIN FORMAT=JSON&lt;/code&gt; in MySQL—will show you whether your joins are using indexes, what the estimated vs. actual row counts look like, and where the query planner is making bad decisions. That's where the real tuning happens.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>database</category>
      <category>hibernate</category>
    </item>
    <item>
      <title>The 1:1 Myth: Why Your CPU Can Handle 400 Threads on 4 Cores</title>
      <dc:creator>Nuno Silva</dc:creator>
      <pubDate>Fri, 13 Feb 2026 18:19:29 +0000</pubDate>
      <link>https://forem.com/nunosilva/the-11-myth-why-your-cpu-can-handle-400-threads-on-4-cores-57il</link>
      <guid>https://forem.com/nunosilva/the-11-myth-why-your-cpu-can-handle-400-threads-on-4-cores-57il</guid>
      <description>&lt;h2&gt;
  
  
  Why This Article Exists
&lt;/h2&gt;

&lt;p&gt;If you're a backend engineer working with Java, Python, Go, or any language with traditional OS threads, you've likely encountered the advice to keep thread pool sizes conservative—often close to your CPU core count.&lt;/p&gt;

&lt;p&gt;This advice appears in Stack Overflow answers and some documentation. It sounds reasonable. But it's based on a fundamental misunderstanding of how CPUs and threads actually work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The confusion stems from vocabulary&lt;/strong&gt;: The word "thread" refers to two completely different things—a &lt;strong&gt;hardware thread&lt;/strong&gt; (a physical execution unit in your CPU) and a &lt;strong&gt;software thread&lt;/strong&gt; (a data structure in your operating system). Engineers often conflate these, leading to catastrophically undersized thread pools.&lt;/p&gt;

&lt;p&gt;This article will dismantle the &lt;strong&gt;1:1 Myth&lt;/strong&gt;—the belief that you need one software thread per hardware thread—and show you why your 4-core CPU can comfortably handle 400 threads without breaking a sweat.&lt;/p&gt;

&lt;p&gt;We'll cover the mechanics, the math, and the real-world constraints. By the end, you'll understand why most production systems are running at 10% capacity while paying for 100%.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Experiment
&lt;/h2&gt;

&lt;p&gt;Open your terminal right now. Type &lt;code&gt;top&lt;/code&gt; or &lt;code&gt;htop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Look at the number of tasks running. Even on a modest laptop, you'll see 2,000+ threads competing for CPU time.&lt;/p&gt;

&lt;p&gt;Now look at your core count. Maybe it's 8. Maybe it's 16.&lt;/p&gt;

&lt;p&gt;If the "1 thread per core" rule were gospel, your computer should have exploded during boot. Yet here we are.&lt;/p&gt;

&lt;p&gt;Now check your production infrastructure. How many threads is your API server running? If you're like most backend teams, you've capped your thread pool to match your core count—8 threads for an 8-core container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You are likely running at 10% capacity while paying for 100%.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Parking Lot Fallacy
&lt;/h2&gt;

&lt;p&gt;There's a widespread fear in backend engineering: the fear of &lt;strong&gt;Oversubscription&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We look at our infrastructure and mentally map it to a parking lot. 8 cores = 8 parking spaces. Creating more than 8 threads feels dangerous—like a traffic jam waiting to happen. Context switching. Thrashing. Performance degradation.&lt;/p&gt;

&lt;p&gt;So we cap our pools. We feel "safe."&lt;/p&gt;

&lt;p&gt;This safety is an illusion. And it's expensive.&lt;/p&gt;

&lt;p&gt;The fundamental error is treating software threads like physical objects that occupy space. Your CPU is not a parking lot with limited spots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your CPU is a high-speed revolving door.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Part I: The Foundation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Decoupling the Worker from the Work
&lt;/h3&gt;

&lt;p&gt;To fix your throughput, you must understand the distinction between two fundamentally different concepts that share the word "thread":&lt;/p&gt;

&lt;h4&gt;
  
  
  1. The Hardware Thread (The Worker)
&lt;/h4&gt;

&lt;p&gt;This is physical silicon. Whether it's a core or a hyper-thread (SMT), a hardware thread is an &lt;strong&gt;execution unit&lt;/strong&gt;—the actual circuitry that runs instructions.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;finite&lt;/strong&gt;. Governed by the laws of physics. If you have 8 cores, you can execute exactly 8 instructions at any given nanosecond. No more.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. The Software Thread (The Work)
&lt;/h4&gt;

&lt;p&gt;A software thread is not a physical thing. In Linux, it's a &lt;code&gt;task_struct&lt;/code&gt;. In the JVM, it's a wrapper around an OS kernel thread. It consists of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stack Memory&lt;/strong&gt; (~1MB) for function call frames and local variables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instruction Pointer&lt;/strong&gt; (current position in the code)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register State&lt;/strong&gt; (CPU's working data—intermediate calculations, pointers, flags)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Creating a software thread does &lt;strong&gt;not&lt;/strong&gt; occupy a core. It creates a &lt;strong&gt;candidate&lt;/strong&gt; for execution—a piece of work that &lt;em&gt;wants&lt;/em&gt; to use a core.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part II: The Illusion
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How 4 Cores Run 100 Threads
&lt;/h3&gt;

&lt;p&gt;They don't. They &lt;strong&gt;take turns&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The OS Scheduler is the traffic cop. It uses &lt;strong&gt;Time Slicing&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Thread A runs on Core 1 for a few microseconds&lt;/li&gt;
&lt;li&gt;The scheduler pauses Thread A and saves its state to RAM (&lt;strong&gt;Context Switch&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Thread B loads onto Core 1 and runs&lt;/li&gt;
&lt;li&gt;Repeat, thousands of times per second&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To the human eye, Threads A and B appear to run simultaneously. To the CPU, they are strictly sequential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visualising Time Slicing on a Single Core:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Time →
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│  A  │  B  │  C  │  A  │  D  │  B  │  A  │  C  │  ...
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
 2μs   2μs   2μs   2μs   2μs   2μs   2μs   2μs

Each thread runs for microseconds (μs), then pauses (context switch).
The CPU rotates through all READY threads, creating the illusion
that all 4 threads are running "at the same time."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why your laptop with 8 cores can juggle 2,000 threads without breaking a sweat.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Context Switching Tax
&lt;/h3&gt;

&lt;p&gt;"But wait," you ask, "isn't context switching expensive? Shouldn't I minimise threads to avoid that overhead?"&lt;/p&gt;

&lt;p&gt;If your threads were encoding video, mining cryptocurrency, or running scientific simulations—yes, context switching would hurt.&lt;/p&gt;

&lt;p&gt;But your threads are probably waiting for databases and APIs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part III: The Key Insight
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Your Threads Are Not Working—They're Waiting
&lt;/h3&gt;

&lt;p&gt;This is the &lt;strong&gt;single most important concept&lt;/strong&gt; in thread pool sizing.&lt;/p&gt;

&lt;p&gt;In 99% of business applications (REST APIs, microservices, web backends), threads spend the vast majority of their lifetime in one state:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BLOCKED&lt;/strong&gt; (Waiting for I/O)&lt;/p&gt;

&lt;h4&gt;
  
  
  The Thread Lifecycle
&lt;/h4&gt;

&lt;p&gt;A thread exists in one of three states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RUNNING&lt;/strong&gt;: Actively using the CPU&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;READY&lt;/strong&gt;: Waiting for the CPU to be free&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BLOCKED&lt;/strong&gt;: Waiting for I/O (Database, Network, File System)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Visualising a Typical Web Request Thread:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP Request Arrives
        ↓
    [RUNNING] ──→ Parse JSON, Route Request (2ms)
        ↓
    [BLOCKED] ──→ Database Query (98ms) ← CPU is FREE
        ↓
    [RUNNING] ──→ Serialize Response (2ms)
        ↓
    Response Sent

Key Insight: During the BLOCKED phase, this thread is 
"off the silicon"—it's in RAM, consuming ZERO CPU cycles.
The CPU is completely free to work on other threads.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  The 98/2 Rule
&lt;/h4&gt;

&lt;p&gt;Consider a typical HTTP request in a Spring Boot API:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total Response Time: 100ms&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2ms: CPU work (parsing JSON, routing, business logic, serialization)&lt;/li&gt;
&lt;li&gt;98ms: &lt;strong&gt;Waiting&lt;/strong&gt; for the database query to return&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;During that 98ms, the thread is in the BLOCKED state. It is &lt;strong&gt;off the silicon&lt;/strong&gt;. It resides in memory, but it consumes &lt;strong&gt;zero CPU cycles&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you follow the 1:1 rule (8 threads for 8 cores) and all 8 threads hit the database simultaneously—which happens constantly—your CPU sits &lt;strong&gt;idle&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You have &lt;strong&gt;0% utilisation&lt;/strong&gt; because all your workers are standing around waiting for the database.&lt;/p&gt;

&lt;p&gt;Meanwhile, there are 100 requests queued up that could be parsed, routed, and submitted to the database &lt;strong&gt;right now&lt;/strong&gt;—if only you had threads available.&lt;/p&gt;

&lt;p&gt;You are paying for a Ferrari and leaving it in the driveway because you're afraid to scratch the paint.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part IV: The Math
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Blocking Coefficient
&lt;/h3&gt;

&lt;p&gt;To maximize throughput, you must &lt;strong&gt;oversubscribe&lt;/strong&gt;. You need enough threads to ensure that every time one thread blocks, another is ready to jump onto the CPU.&lt;/p&gt;

&lt;p&gt;We can derive the optimal pool size using a heuristic based on &lt;strong&gt;Little's Law&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N_threads = N_cpu × (1 + Wait_Time / Compute_Time)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a heuristic, not a law. It assumes stable workload characteristics and minimal contention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Definitions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compute_Time&lt;/strong&gt;: Actual CPU work (parsing, logic, serialization)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait_Time&lt;/strong&gt;: Time spent blocked on I/O, including:

&lt;ul&gt;
&lt;li&gt;Database queries&lt;/li&gt;
&lt;li&gt;Network latency (external APIs, microservice calls)&lt;/li&gt;
&lt;li&gt;Disk I/O (file reads/writes)&lt;/li&gt;
&lt;li&gt;Lock contention (waiting for synchronized blocks)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;The ratio &lt;code&gt;Wait_Time / Compute_Time&lt;/code&gt; is your &lt;strong&gt;Blocking Coefficient&lt;/strong&gt;—the multiplier that tells you how many threads you need to keep your CPUs saturated.&lt;/p&gt;

&lt;p&gt;Let's apply this to our web API scenario:&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;N_cpu = 4 cores&lt;/li&gt;
&lt;li&gt;Wait Time = 98ms (database)&lt;/li&gt;
&lt;li&gt;Compute Time = 2ms (actual CPU work)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Calculate the Ratio:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Wait_Time / Compute_Time = 98 / 2 = 49
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Optimal Thread Pool Size:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N_threads = 4 × (1 + 49) = 4 × 50 = 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;You need 200 software threads to keep 4 hardware threads fully utilised.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you capped your pool at 4 threads, you are artificially bottlenecking your throughput by &lt;strong&gt;50x&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Concrete Example
&lt;/h3&gt;

&lt;p&gt;Let's say your API can handle 10,000 requests per second with the optimal pool size (200 threads).&lt;/p&gt;

&lt;p&gt;With the 1:1 mapping (4 threads), you'd be limited to approximately 200 requests per second—not because your CPU is slow, but because you're &lt;strong&gt;refusing to use it&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part V: The Real Limits
&lt;/h2&gt;

&lt;h3&gt;
  
  
  This Doesn't Mean Threads Are Free
&lt;/h3&gt;

&lt;p&gt;You cannot spawn infinite threads. You are bounded by three constraints:&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Memory Constraints
&lt;/h4&gt;

&lt;p&gt;Each Java thread reserves stack space:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;200 threads&lt;/strong&gt; ≈ 200MB of RAM (manageable)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1,000 threads&lt;/strong&gt; ≈ 1GB of RAM (still fine)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10,000 threads&lt;/strong&gt; ≈ 10GB of RAM (problematic)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack memory is the primary constraint in traditional threading models. This is why &lt;strong&gt;Virtual Threads&lt;/strong&gt; (Project Loom) were invented—they use growable stacks with much smaller footprints.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. The Thrashing Point
&lt;/h4&gt;

&lt;p&gt;If your workload suddenly shifts and all 200 threads become &lt;strong&gt;CPU-bound&lt;/strong&gt; simultaneously (e.g., they stop waiting and start doing heavy computation), the OS will choke on context switching.&lt;/p&gt;

&lt;p&gt;The scheduler will spend more time swapping threads than actually running them. This is &lt;strong&gt;thrashing&lt;/strong&gt;, and it kills performance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical note&lt;/strong&gt;: Thrashing also occurs when threads do very brief work between blocks. If a thread wakes up, does 1 microsecond of work, then blocks again, the context switch overhead (saving/loading state) exceeds the actual execution time. The CPU spends more time managing threads than running them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache Pollution&lt;/strong&gt;: Context switching isn't just about saving registers—it destroys the L1/L2 CPU cache. When Thread B loads onto a core, it has to fetch its data from RAM (slow, ~100ns) because Thread A filled the cache with its own data. This &lt;strong&gt;cache pollution&lt;/strong&gt; is the hidden tax of oversubscription. With excessive context switching, your CPU can spend more time waiting for RAM than executing instructions.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Downstream Bottlenecks (The Real Limit)
&lt;/h4&gt;

&lt;p&gt;Increasing your thread pool size does &lt;strong&gt;not&lt;/strong&gt; magically increase system capacity. You're often bounded by downstream constraints:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What threads don't fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database connection pool limits&lt;/li&gt;
&lt;li&gt;External API rate limits&lt;/li&gt;
&lt;li&gt;Lock contention&lt;/li&gt;
&lt;li&gt;Network bandwidth&lt;/li&gt;
&lt;li&gt;Downstream service capacity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What oversized pools can cause:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database connection exhaustion&lt;/li&gt;
&lt;li&gt;Cascading failures in microservices&lt;/li&gt;
&lt;li&gt;Amplified lock contention&lt;/li&gt;
&lt;li&gt;Queueing in unexpected places&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Critical coordination points:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your thread pool must align with your DB connection pool&lt;/li&gt;
&lt;li&gt;HTTP client pools must be sized appropriately&lt;/li&gt;
&lt;li&gt;Rate limiters and circuit breakers should be in place&lt;/li&gt;
&lt;li&gt;Downstream services need capacity for your load&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The formula gives you the thread count needed to saturate your &lt;strong&gt;CPU&lt;/strong&gt;. But production systems are rarely CPU-bound—they're usually constrained by databases, downstream APIs, or other shared resources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before increasing threads, verify your bottleneck is actually CPU starvation.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Safeguard: Proper Workload Classification
&lt;/h3&gt;

&lt;p&gt;The formula works because of the assumption that threads are I/O-bound. If that assumption breaks, the formula breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CPU-Bound Workload&lt;/strong&gt; (video encoding, cryptography, scientific computing):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Threads ≈ Cores&lt;/li&gt;
&lt;li&gt;Maybe cores × 1.5 if you want some overlap during cache misses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;I/O-Bound Workload&lt;/strong&gt; (web APIs, database-backed services, microservices):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Threads = Cores × (1 + Wait/Compute)&lt;/li&gt;
&lt;li&gt;Often 10x-50x the core count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Mixed Workload&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Measure your actual wait/compute ratio&lt;/li&gt;
&lt;li&gt;Test empirically&lt;/li&gt;
&lt;li&gt;Monitor CPU utilisation and response times&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part VI: Practical Takeaways
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How to Right-Size Your Thread Pool
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Profile your application&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Measure actual CPU time vs. wait time for typical requests&lt;/li&gt;
&lt;li&gt;Use APM tools (New Relic, Datadog) or profilers (JFR, async-profiler)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Apply the formula&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;N_threads = N_cpu × (1 + Wait / Compute)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Start conservative, then increase&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Load test and monitor&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Watch CPU utilisation (should be 70-90% under load)&lt;/li&gt;
&lt;li&gt;Watch response times (should remain stable as load increases)&lt;/li&gt;
&lt;li&gt;Watch thread pool queue depth (should stay near zero)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Iterate&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If CPU is maxed but latency is good: You're optimal&lt;/li&gt;
&lt;li&gt;If CPU is low and latency is increasing: Not enough threads or downstream bottleneck&lt;/li&gt;
&lt;li&gt;If CPU is oscillating wildly: Possible thrashing (too many threads for the workload)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Monitoring Signals
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What to watch:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU utilisation&lt;/strong&gt;: Should be high (70-90%) under load if properly sized&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thread pool queue depth&lt;/strong&gt;: Should stay near zero; growth indicates undersized pool or downstream bottleneck&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response time percentiles&lt;/strong&gt; (p50, p95, p99): Should remain stable as load increases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context switch rate&lt;/strong&gt;: Dramatic increases may indicate thrashing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GC pauses&lt;/strong&gt; (JVM): Excessive pauses may indicate memory pressure from too many threads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database wait times&lt;/strong&gt;: High waits suggest downstream, not thread pool, is the bottleneck&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Symptom diagnosis:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Low CPU + rising latency&lt;/strong&gt; → Pool too small OR downstream bottleneck (check DB connection pool, external API limits)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High CPU + unstable latency&lt;/strong&gt; → Possible thrashing or CPU-bound workload with too many threads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High CPU + stable latency&lt;/strong&gt; → You're optimal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue depth growing&lt;/strong&gt; → Undersized pool or downstream can't keep up&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Common Thread Pool Sizes for I/O-Bound Services
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CPU Cores&lt;/th&gt;
&lt;th&gt;Typical Wait/Compute Ratio&lt;/th&gt;
&lt;th&gt;Optimal Threads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;10:1 (DB-backed API)&lt;/td&gt;
&lt;td&gt;40-50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;50:1 (High-latency external APIs)&lt;/td&gt;
&lt;td&gt;200+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;20:1 (Microservice)&lt;/td&gt;
&lt;td&gt;160-180&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;10:1 (Standard web app)&lt;/td&gt;
&lt;td&gt;160-200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: These numbers assume a traditional blocking I/O model with OS threads (Java platform threads, Python threads, etc.). If using &lt;strong&gt;Virtual Threads&lt;/strong&gt; (Java 21+), these memory-based limits disappear—you can run 100k+ virtual threads per JVM, and the optimal pool size becomes effectively unlimited for I/O-bound workloads.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Platform Caveats
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Python and the GIL
&lt;/h4&gt;

&lt;p&gt;Python's Global Interpreter Lock (GIL) prevents true parallel execution of Python bytecode across threads.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;CPU-bound Python threads do &lt;strong&gt;not&lt;/strong&gt; execute in parallel&lt;/li&gt;
&lt;li&gt;I/O-bound Python threads still benefit from concurrency (I/O operations release the GIL)&lt;/li&gt;
&lt;li&gt;Thread pool sizing for CPU-bound Python work doesn't follow the same rules as JVM or Go&lt;/li&gt;
&lt;li&gt;Consider multiprocessing (separate processes) for CPU-bound parallelism&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What About Reactive/Async?
&lt;/h3&gt;

&lt;p&gt;Reactive frameworks (WebFlux, Vert.x, Node.js) take a different approach: they use &lt;strong&gt;event loops&lt;/strong&gt; with a small thread pool (often matching cores) and &lt;strong&gt;non-blocking I/O&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of blocking threads during waits, they register callbacks and release the thread immediately. This achieves high concurrency with minimal threads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-off&lt;/strong&gt;: Significantly more complex programming model. You give up the straightforward imperative style for callback hell or coroutine complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With Virtual Threads (Java 21+)&lt;/strong&gt;, you get the throughput of async with the simplicity of blocking code. Virtual threads are so cheap (100k+ per JVM) that you can write natural, sequential code while achieving the concurrency of reactive frameworks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Stop treating your CPU core count as a hard limit for your thread pool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's a baseline, not a ceiling.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The "safety" of 1:1 thread-to-core mapping is an illusion that leaves your infrastructure dramatically underutilised.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Rules
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For CPU-Bound tasks&lt;/strong&gt;: Threads ≈ Cores&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For I/O-Bound tasks&lt;/strong&gt;: Trust the math. Oversubscribe aggressively.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your CPU is designed to juggle. It's built for time-slicing. It &lt;em&gt;wants&lt;/em&gt; to handle hundreds of threads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Just make sure the rest of your system can keep up.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Java Concurrency in Practice&lt;/strong&gt; (Brian Goetz) - Chapter 8.2: Sizing Thread Pools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Little's Law&lt;/strong&gt; - The mathematical foundation for queue theory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project Loom / Virtual Threads&lt;/strong&gt; - The future of Java concurrency&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>concurrency</category>
      <category>performance</category>
      <category>backend</category>
      <category>threading</category>
    </item>
    <item>
      <title>Breaking the Sequential Ceiling: High-Performance Concurrency in Java 8 Enterprise Systems</title>
      <dc:creator>Nuno Silva</dc:creator>
      <pubDate>Wed, 11 Feb 2026 19:03:12 +0000</pubDate>
      <link>https://forem.com/nunosilva/breaking-the-sequential-ceiling-high-performance-concurrency-in-java-8-enterprise-systems-14k7</link>
      <guid>https://forem.com/nunosilva/breaking-the-sequential-ceiling-high-performance-concurrency-in-java-8-enterprise-systems-14k7</guid>
      <description>&lt;p&gt;Modern applications call five, ten, even twenty downstream services per request. Virtual threads (Java 21) and reactive frameworks solve this elegantly — but in 2026, a significant portion of enterprise Java still runs on &lt;strong&gt;Java 8 and Spring Boot 2.7&lt;/strong&gt;. Whether it's regulatory constraints, vendor dependencies, or the sheer inertia of large codebases, upgrading the JVM isn't always an option — and these teams still need practical solutions.&lt;/p&gt;

&lt;p&gt;This article shows how to achieve real concurrency gains in legacy Java using the &lt;strong&gt;Bulkhead Pattern&lt;/strong&gt;, explicit thread pool isolation, and &lt;code&gt;CompletableFuture&lt;/code&gt;. We'll walk through the theory, then validate it with &lt;strong&gt;Project IronThread&lt;/strong&gt; — a proof-of-concept that achieves a &lt;strong&gt;41% latency reduction by parallelizing previously sequential service calls&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Sequential Tax
&lt;/h2&gt;

&lt;p&gt;A typical dashboard endpoint might aggregate data from three services:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;User Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Order Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;500 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Recommendations Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Called sequentially, these produce a hard ceiling of &lt;strong&gt;1 700 ms&lt;/strong&gt;. Run them in parallel and the total drops to the duration of the slowest call — &lt;strong&gt;1 000 ms&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Execution&lt;/th&gt;
&lt;th&gt;Total Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sequential&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User → Orders → Recs&lt;/td&gt;
&lt;td&gt;200 + 500 + 1 000 = &lt;strong&gt;1 700 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Parallel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User, Orders, Recs (concurrent)&lt;/td&gt;
&lt;td&gt;max(200, 500, 1 000) = &lt;strong&gt;1 000 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Under load, this ceiling becomes a wall. With Tomcat's default 200 worker threads and each request taking 1.7 seconds, you can only handle ~117 requests per second before exhausting the thread pool.&lt;/p&gt;

&lt;p&gt;The root issue: &lt;strong&gt;unnecessary serialization&lt;/strong&gt;. Three independent network calls are forced to wait on each other.&lt;/p&gt;

&lt;p&gt;Reducing latency isn't just about UX — it directly affects scalability. Shorter request durations release threads sooner, increasing effective throughput and reducing queueing delays under sustained load.&lt;/p&gt;




&lt;h2&gt;
  
  
  The &lt;code&gt;ForkJoinPool&lt;/code&gt; Trap
&lt;/h2&gt;

&lt;p&gt;The obvious first move is &lt;code&gt;CompletableFuture&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;userF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;callUserService&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without an explicit executor, this defaults to &lt;code&gt;ForkJoinPool.commonPool()&lt;/code&gt; — a shared pool designed for &lt;strong&gt;CPU-bound fork/join tasks&lt;/strong&gt;, not blocking I/O.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Breaks Down
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shared global resource.&lt;/strong&gt; The common pool is shared across the entire JVM. One endpoint flooding it with I/O starves everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sizing mismatch.&lt;/strong&gt; Default pool size is &lt;code&gt;availableProcessors() - 1&lt;/code&gt;. On an 8-core machine, that's 7 threads for the whole application. Ten concurrent dashboard requests create 30 blocking operations against a 7-thread pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No isolation.&lt;/strong&gt; A single misbehaving endpoint degrades the entire system.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;ForkJoinPool&lt;/code&gt; does provide &lt;code&gt;ManagedBlocker&lt;/code&gt; to mitigate blocking scenarios, but it's rarely used in enterprise applications and doesn't address workload isolation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Virtual threads in Java 21 eliminate this class of problem entirely. For Java 8, the answer is &lt;strong&gt;explicit thread pool isolation&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bulkhead Pattern
&lt;/h2&gt;

&lt;p&gt;Named after the watertight compartments in a ship's hull, the &lt;strong&gt;Bulkhead Pattern&lt;/strong&gt; dedicates separate thread pools to distinct workload types. Each pool is tuned to its workload characteristics, and failures in one pool can't cascade to others.&lt;/p&gt;

&lt;p&gt;Spring's &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; provides a clean implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ioTaskExecutor"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Executor&lt;/span&gt; &lt;span class="nf"&gt;ioTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCorePoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMaxPoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueueCapacity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setThreadNamePrefix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"IO-Pool-"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRejectedExecutionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CallerRunsPolicy&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Sizing Guidance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Core Pool Size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Threads kept alive indefinitely.&lt;/td&gt;
&lt;td&gt;For I/O-bound work: &lt;code&gt;cores × (1 + wait_time / compute_time)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Max Pool Size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Burst capacity when core threads are busy and the queue is full.&lt;/td&gt;
&lt;td&gt;2–3× core size is a reasonable starting point.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Queue Capacity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Buffers tasks before spawning additional threads.&lt;/td&gt;
&lt;td&gt;Deep queues smooth transient spikes but increase tail latency. In latency-sensitive systems, prefer bounded queues with an explicit rejection policy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rejection Policy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Defines what happens when both the pool and queue are full.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CallerRunsPolicy&lt;/code&gt; applies back-pressure by running the task on the submitting thread. &lt;code&gt;AbortPolicy&lt;/code&gt; (the default) throws an exception. Choose based on whether you prefer degraded latency or fast failure.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Thread Name Prefix&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Makes thread dumps self-documenting.&lt;/td&gt;
&lt;td&gt;Always set this — you'll thank yourself during production debugging.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip — Observability:&lt;/strong&gt; &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; exposes its active count, queue size, and pool size at runtime. In production, wire these metrics to Micrometer / Spring Boot Actuator to detect saturation before it becomes a problem.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Orchestrating Parallel Calls with &lt;code&gt;CompletableFuture&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;With isolated pools in place, orchestration is straightforward. For two futures, &lt;code&gt;thenCombine&lt;/code&gt; works well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;userF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;callUserService&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ioTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ordersF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;callOrderService&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ioTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashboardData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userF&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thenCombine&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ordersF&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DashboardData&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For three or more independent futures, &lt;code&gt;CompletableFuture.allOf()&lt;/code&gt; combined with &lt;code&gt;thenApply()&lt;/code&gt; is cleaner — we'll see this in the case study below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The execution model:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;supplyAsync()&lt;/code&gt; submits tasks to &lt;code&gt;ioTaskExecutor&lt;/code&gt; and returns immediately. The calling thread does not block.&lt;/li&gt;
&lt;li&gt;Worker threads execute the service calls in parallel.&lt;/li&gt;
&lt;li&gt;Non-async continuations like &lt;code&gt;thenCombine()&lt;/code&gt; run on whichever thread completes the last required stage — no additional thread is spawned, no unnecessary context switch.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Handling Partial Failure
&lt;/h3&gt;

&lt;p&gt;Distributed systems fail routinely. The key is failing gracefully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;recsF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;callRecommendationsService&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ioTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exceptionally&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Recommendations Unavailable"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.exceptionally()&lt;/code&gt; transforms a failure into degraded success. The user still gets their profile and orders — just without recommendations. No exceptions propagate, no cascading failures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Case Study: Project IronThread
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Project IronThread&lt;/strong&gt; applies these principles to a dashboard aggregation service. Three mock services simulate realistic downstream behaviour:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;th&gt;Failure Rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;User Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200 ms&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Order Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;500 ms&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Recommendations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 000 ms&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Executor Configuration
&lt;/h3&gt;

&lt;p&gt;The teaching example above used &lt;code&gt;corePoolSize=20&lt;/code&gt; for a production system handling multiple workload types. IronThread uses a smaller pool — it's a single-service proof-of-concept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ironThreadExecutor"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Executor&lt;/span&gt; &lt;span class="nf"&gt;taskExecutor&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCorePoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMaxPoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueueCapacity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setThreadNamePrefix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"IronThread-"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Async Pipeline
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashboardResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getDashboardAsync&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;userF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nl"&gt;downstreamService:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getUserDetails&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ironThreadExecutor&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ordersF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nl"&gt;downstreamService:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getOrders&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ironThreadExecutor&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;recsF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supplyAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="nl"&gt;downstreamService:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getRecommendations&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ironThreadExecutor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exceptionally&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"Recs:Fallback"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userF&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ordersF&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recsF&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thenApply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;voidResult&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DashboardResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userDetails&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userF&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ordersF&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recommendations&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recsF&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executionTime&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;threadName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentThread&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="o"&gt;});&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three calls fire immediately on the &lt;code&gt;ironThreadExecutor&lt;/code&gt;. &lt;code&gt;.exceptionally()&lt;/code&gt; on the recommendations future provides graceful degradation. &lt;code&gt;CompletableFuture.allOf()&lt;/code&gt; guarantees all futures complete before &lt;code&gt;.thenApply()&lt;/code&gt; executes, so the &lt;code&gt;join()&lt;/code&gt; calls simply retrieve already-available results — they don't block.&lt;/p&gt;

&lt;h3&gt;
  
  
  What About Timeouts?
&lt;/h3&gt;

&lt;p&gt;One thing this implementation &lt;em&gt;doesn't&lt;/em&gt; handle is timeouts. If a downstream service hangs for 30 seconds, the thread is held indefinitely — which is the same starvation problem we're trying to avoid, just in a different pool.&lt;/p&gt;

&lt;p&gt;Java 9+ introduced &lt;code&gt;orTimeout()&lt;/code&gt; and &lt;code&gt;completeOnTimeout()&lt;/code&gt;, but on Java 8, you'd need a &lt;code&gt;ScheduledExecutorService&lt;/code&gt; that completes the future exceptionally after a deadline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ScheduledExecutorService&lt;/span&gt; &lt;span class="n"&gt;scheduler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;Executors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newScheduledThreadPool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;CompletableFuture&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;scheduler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completeExceptionally&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TimeoutException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Timed out"&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that this simplified version still fires the scheduled task even if the future completes normally (the &lt;code&gt;completeExceptionally&lt;/code&gt; call simply returns &lt;code&gt;false&lt;/code&gt; on an already-completed future). Production code would typically use &lt;code&gt;whenComplete()&lt;/code&gt; to cancel the scheduled task.&lt;/p&gt;

&lt;p&gt;This is left out of IronThread's demo code for simplicity, but in production systems, &lt;strong&gt;timeout handling is essential&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Benchmark Results
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; These measurements are illustrative, not a formal benchmark. They measure &lt;strong&gt;single-request latency&lt;/strong&gt;, not throughput under concurrent load. Runs were executed after JVM warm-up with mocked downstream latency. The goal is to demonstrate the architectural impact of parallelization.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Environment:&lt;/strong&gt; Apple MacBook Pro M3 Pro (11-core CPU, 18 GB Unified Memory)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;===================================================================
Run # | Strategy   | Time (ms)  | Thread Name          | Status    
===================================================================
1     | Blocking   | 1708       | main                 | Success   
2     | Blocking   | 1704       | main                 | Success   
3     | Blocking   | 1712       | main                 | Success   
4     | Blocking   | 1709       | main                 | Success   
5     | Blocking   | 1707       | main                 | Success   
-------------------------------------------------------------------
1     | Async      | 1006       | IronThread-6         | Success   
2     | Async      | 1003       | IronThread-9         | Success   
3     | Async      | 1008       | IronThread-12        | Partial   
4     | Async      | 1004       | IronThread-15        | Success   
5     | Async      | 1009       | IronThread-18        | Success   
===================================================================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key observations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;41% latency reduction&lt;/strong&gt; — the direct result of parallelizing three independent calls. Async averages ~1 006 ms (bounded by the slowest call) vs. blocking's ~1 708 ms (sum of all calls). This isn't a novel optimisation; it's the expected outcome once you remove unnecessary sequential execution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pool isolation verified.&lt;/strong&gt; Every async run executes on &lt;code&gt;IronThread-*&lt;/code&gt; workers — not on the common pool or Tomcat threads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful degradation works.&lt;/strong&gt; Run 3 shows a partial failure — recommendations failed, but the dashboard still loaded with user and order data intact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A natural next step would be to validate this under concurrent load — simulating 50+ simultaneous requests with a tool like JMeter or wrk to measure throughput, queue saturation, and tail latency behaviour.&lt;/p&gt;




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

&lt;p&gt;The 41% latency improvement is the natural result of parallelizing independent calls that were previously sequential. It comes from three deliberate decisions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Explicit thread pool isolation&lt;/strong&gt; — avoid &lt;code&gt;ForkJoinPool.commonPool()&lt;/code&gt; for blocking I/O.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel execution&lt;/strong&gt; — use &lt;code&gt;CompletableFuture.allOf()&lt;/code&gt; to fire independent calls concurrently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful degradation&lt;/strong&gt; — use &lt;code&gt;.exceptionally()&lt;/code&gt; to contain failures without cascading.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No Java 21 required. No reactive framework. Just understanding when threads block and respecting pool boundaries.&lt;/p&gt;

&lt;p&gt;Independent network calls that &lt;em&gt;can&lt;/em&gt; happen in parallel &lt;em&gt;should&lt;/em&gt; happen in parallel. The Bulkhead Pattern ensures that doing so doesn't create new failure modes.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a href="https://github.com/nunosilva-dev/iron-thread" rel="noopener noreferrer"&gt;github.com/nunosilva-dev/iron-thread&lt;/a&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>performance</category>
      <category>concurrency</category>
    </item>
  </channel>
</rss>
