<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kyryl</title>
    <description>The latest articles on DEV Community by Kyryl (@code_with_kyryl).</description>
    <link>https://dev.to/code_with_kyryl</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1555526%2F850a315e-27a2-410d-85db-cb6a771c189b.jpg</url>
      <title>DEV Community: Kyryl</title>
      <link>https://dev.to/code_with_kyryl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/code_with_kyryl"/>
    <language>en</language>
    <item>
      <title>🗑️ Kafka Keeps Data Longer Than retention.ms</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 20 Jul 2026 18:52:46 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/kafka-keeps-data-longer-than-retentionms-16ak</link>
      <guid>https://dev.to/code_with_kyryl/kafka-keeps-data-longer-than-retentionms-16ak</guid>
      <description>&lt;p&gt;You set &lt;code&gt;retention.ms&lt;/code&gt; to one hour. Six hours later the data is still on disk. That is not a bug, and it is not your cleanup thread being slow.&lt;/p&gt;

&lt;p&gt;Kafka deletes data very differently from how most people picture it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3na5txohfm7o8qoqhr6d.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3na5txohfm7o8qoqhr6d.gif" alt="How Kafka retention works: it deletes whole closed segments, never the active one" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Kafka deletes segments, not records
&lt;/h2&gt;

&lt;p&gt;A partition is not one big file. It is an ordered sequence of &lt;strong&gt;segments&lt;/strong&gt;, each a file on disk. Retention operates at the segment level, never at the record level.&lt;/p&gt;

&lt;p&gt;Kafka will never open a segment and rewrite it to drop a handful of expired records. That would be expensive and would break the append-only design the whole system is built on. Instead it waits until an entire segment is eligible, then deletes the whole file in one move.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a segment is actually eligible
&lt;/h2&gt;

&lt;p&gt;A closed segment becomes eligible for deletion only when &lt;strong&gt;both&lt;/strong&gt; are true:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The segment is &lt;strong&gt;closed&lt;/strong&gt; (rolled). A segment rolls when it fills up (&lt;code&gt;segment.bytes&lt;/code&gt;, default 1 GB) or ages out (&lt;code&gt;segment.ms&lt;/code&gt;, default 7 days).&lt;/li&gt;
&lt;li&gt;Its &lt;strong&gt;newest&lt;/strong&gt; record is older than &lt;code&gt;retention.ms&lt;/code&gt; (or the partition is over &lt;code&gt;retention.bytes&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note it is the &lt;em&gt;newest&lt;/em&gt; record in the segment that has to age out, not the oldest. One young record keeps the whole segment, and every older record in it, alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The active segment never dies
&lt;/h2&gt;

&lt;p&gt;Here is the part that surprises people, and the reason your data outlives its retention.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;active segment&lt;/strong&gt;, the one currently being written, is never eligible for deletion, no matter what. Retention only ever considers closed segments.&lt;/p&gt;

&lt;p&gt;So on a low-traffic topic, the active segment fills slowly and rolls rarely. The oldest record sitting in it can be hours or days past &lt;code&gt;retention.ms&lt;/code&gt;, just waiting for the segment to finally roll so it can even be &lt;em&gt;considered&lt;/em&gt; for deletion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ segment 0 ][ segment 1 ][ segment 2 ][ segment 3 (active) ]
   closed       closed       closed        being written
   deletable    deletable    deletable     NEVER deletable
   once past retention.ms                  regardless of age
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your retention is effectively &lt;code&gt;retention.ms&lt;/code&gt; &lt;strong&gt;plus&lt;/strong&gt; however long it takes the active segment to roll.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;The fix is not to lower &lt;code&gt;retention.ms&lt;/code&gt; further. That does nothing while the segment has not rolled. Lower &lt;strong&gt;&lt;code&gt;segment.ms&lt;/code&gt;&lt;/strong&gt; so segments roll more often and become deletable sooner.&lt;/p&gt;

&lt;p&gt;But smaller segments are not free: more files, more open file handles, more frequent rolls, and more index overhead. On a high-throughput topic the default 1 GB / 7 day segments are fine and you will never notice this. It only bites on &lt;strong&gt;low-traffic topics with a tight retention expectation&lt;/strong&gt;: compliance windows, PII deletion SLAs, "we only keep 24 hours" promises. Those are exactly the cases where the gap matters, so size the segment to the retention you actually need.&lt;/p&gt;

&lt;p&gt;This is separate from log compaction (&lt;code&gt;cleanup.policy=compact&lt;/code&gt;), which keeps the latest value per key instead of deleting by age. Same segment mechanics underneath, different eligibility rule.&lt;/p&gt;

&lt;p&gt;Have you ever had data outlive its configured retention on a quiet topic, and traced it back to a segment that just never rolled?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>dataengineering</category>
      <category>backend</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>🔀 Your Kafka Group Freezes on Every Deploy</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Thu, 16 Jul 2026 19:09:08 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-kafka-group-freezes-on-every-deploy-45jd</link>
      <guid>https://dev.to/code_with_kyryl/your-kafka-group-freezes-on-every-deploy-45jd</guid>
      <description>&lt;p&gt;You have a Kafka consumer group with four consumers, humming along. Traffic climbs, so you add a fifth. For a few seconds, every consumer stops. Lag spikes. Then it recovers and nobody thinks about it again.&lt;/p&gt;

&lt;p&gt;That pause is not a bug. It is eager rebalancing, and most groups still run it by default.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pt71ts944vz719um45z.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pt71ts944vz719um45z.gif" alt="Kafka rebalance strategies: what happens to partition assignment when a fifth consumer joins" width="600" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebalancing is not the enemy
&lt;/h2&gt;

&lt;p&gt;A consumer group rebalances whenever membership changes: a consumer joins, leaves, crashes, or the topic gains partitions. The group has to agree on who owns which partitions. That part is unavoidable.&lt;/p&gt;

&lt;p&gt;The cost is not the reassignment. It is &lt;em&gt;how&lt;/em&gt; the group gets there. That is controlled by &lt;code&gt;partition.assignment.strategy&lt;/code&gt;, and the choice you make there decides whether a deploy costs you a few milliseconds or a few seconds of frozen consumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four assignors
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RangeAssignor&lt;/strong&gt; (the historical default) assigns each consumer a contiguous range of partitions, per topic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6 partitions, 3 consumers:
A -&amp;gt; P0 P1   B -&amp;gt; P2 P3   C -&amp;gt; P4 P5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean when the numbers divide. The moment they do not, the lower-id consumers get the extra partitions, and if you subscribe to several topics they pile up on the same consumers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RoundRobinAssignor&lt;/strong&gt; deals every partition out one by one across all consumers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6 partitions, 4 consumers:
A -&amp;gt; P0 P4   B -&amp;gt; P1 P5   C -&amp;gt; P2   D -&amp;gt; P3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even spread. But it has no memory. Every rebalance recomputes the whole assignment from scratch, so partitions jump around even when they did not need to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;StickyAssignor&lt;/strong&gt; keeps the spread even &lt;em&gt;and&lt;/em&gt; tries to preserve the previous assignment, so it moves as few partitions as possible on each rebalance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CooperativeStickyAssignor&lt;/strong&gt; does the same assignment as sticky, but over a different rebalance protocol. This is the one that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eager vs cooperative: the real difference
&lt;/h2&gt;

&lt;p&gt;Here is the part that trips people up. Sticky reduces how many partitions &lt;em&gt;move&lt;/em&gt;. It does not change what happens to the ones that &lt;em&gt;stay&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Range, round-robin, and plain sticky all use the &lt;strong&gt;eager&lt;/strong&gt; protocol. On every rebalance the group does a full stop-the-world:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every consumer revokes &lt;strong&gt;all&lt;/strong&gt; of its partitions.&lt;/li&gt;
&lt;li&gt;Nobody consumes anything.&lt;/li&gt;
&lt;li&gt;The new assignment is computed.&lt;/li&gt;
&lt;li&gt;Consumers pick their partitions back up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So even a partition that ends up on the exact same consumer still gets revoked and paused. Add one consumer to a group of ten and all of them stop, for every partition, until the dust settles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cooperative-sticky&lt;/strong&gt; (KIP-429, Kafka 2.4+) rebalances incrementally:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Only the partitions that actually need to move are revoked.&lt;/li&gt;
&lt;li&gt;Every other partition keeps being consumed, right through the rebalance.&lt;/li&gt;
&lt;li&gt;The moved partitions get reassigned in a second, short round.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One consumer joins, one or two partitions pause for a moment, and the rest of the group never notices. Stop-the-world becomes stop-one-partition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConsumerConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PARTITION_ASSIGNMENT_STRATEGY_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CooperativeStickyAssignor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  You cannot just flip the config
&lt;/h2&gt;

&lt;p&gt;This is the trap. You cannot take a running group on the eager protocol and switch it to cooperative in a single deploy. A group with some members speaking eager and some speaking cooperative will not rebalance correctly.&lt;/p&gt;

&lt;p&gt;The supported path is a two-phase rolling upgrade:&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;// Phase 1: deploy every instance with BOTH strategies listed.&lt;/span&gt;
&lt;span class="c1"&gt;// The group stays on the old protocol until all members support the new one.&lt;/span&gt;
&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConsumerConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PARTITION_ASSIGNMENT_STRATEGY_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CooperativeStickyAssignor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;RangeAssignor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

&lt;span class="c1"&gt;// Phase 2: after every instance is running phase 1, deploy again&lt;/span&gt;
&lt;span class="c1"&gt;// with only the cooperative strategy. Now the group flips protocols.&lt;/span&gt;
&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ConsumerConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PARTITION_ASSIGNMENT_STRATEGY_CONFIG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CooperativeStickyAssignor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip phase 1 and you get a broken rebalance in production. Two deploys, in order, with the whole fleet on phase 1 before phase 2 starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Cooperative-sticky is not free.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The migration is a two-step rolling upgrade you have to get right, not a one-line change.&lt;/li&gt;
&lt;li&gt;Incremental rebalancing can take &lt;strong&gt;more&lt;/strong&gt; rounds than a single eager rebalance, so the total rebalance can be longer even though nobody is fully stopped.&lt;/li&gt;
&lt;li&gt;Your &lt;code&gt;onPartitionsRevoked&lt;/code&gt; and &lt;code&gt;onPartitionsLost&lt;/code&gt; callbacks need to be correct, because partitions now come and go without a global reset to lean on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a group that rebalances rarely and briefly, the eager pause may be cheap enough to ignore. For a group that scales, deploys, or loses instances often, the stop-the-world pause is a tax you pay on every event, and cooperative-sticky removes it.&lt;/p&gt;

&lt;p&gt;Which assignor does your consumer group run today, and have you ever actually measured how long a rolling deploy freezes it?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>java</category>
      <category>distributedsystems</category>
      <category>backend</category>
    </item>
    <item>
      <title>🔄 Your Database Is Already an Event Stream</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Thu, 09 Jul 2026 20:35:01 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-database-is-already-an-event-stream-3cmn</link>
      <guid>https://dev.to/code_with_kyryl/your-database-is-already-an-event-stream-3cmn</guid>
      <description>&lt;p&gt;You have a service that owns the &lt;code&gt;orders&lt;/code&gt; table. Three other teams need to know when an order changes. So you do the obvious thing: after every write, you publish an event to Kafka.&lt;/p&gt;

&lt;p&gt;Then someone adds a new write path and forgets the publish. Now the order exists in the database but no event ever fired. The downstream read models drift. You find out three weeks later when finance asks why the numbers do not match.&lt;/p&gt;

&lt;p&gt;This is the dual-write problem, and you cannot discipline your way out of it. Every write path is a place someone forgets to emit the event.&lt;/p&gt;

&lt;p&gt;There is a cleaner option. Stop publishing events by hand. Let the database do it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvu23hks9q2q23gl59dcs.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvu23hks9q2q23gl59dcs.gif" alt="CDC pipeline: Postgres WAL to Debezium to Kafka to Spring Boot consumers, every row change becomes an event" width="720" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The database already knows what changed
&lt;/h2&gt;

&lt;p&gt;Change Data Capture (CDC) turns your database into an event source. Debezium reads the write-ahead log, the same log Postgres already uses for replication and crash recovery, and turns every committed row change into a Kafka event.&lt;/p&gt;

&lt;p&gt;Point a connector at a table. Every insert, update, and delete becomes a message on a topic. Downstream services build their own read models from that stream and never query your tables directly.&lt;/p&gt;

&lt;p&gt;The write already happened. The WAL already recorded it. There is nothing to remember and nothing to enforce in application code, because the event is a byproduct of the commit, not a second action you have to bolt onto every write path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it up
&lt;/h2&gt;

&lt;p&gt;A Debezium Postgres connector is a bit of connector config, not application code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders-connector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"connector.class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"io.debezium.connector.postgresql.PostgresConnector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database.hostname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database.dbname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plugin.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pgoutput"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"table.include.list"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public.orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"topic.prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shop"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changes to &lt;code&gt;public.orders&lt;/code&gt; now land on the &lt;code&gt;shop.public.orders&lt;/code&gt; topic. Each message carries a &lt;code&gt;before&lt;/code&gt; and &lt;code&gt;after&lt;/code&gt; image of the row plus an &lt;code&gt;op&lt;/code&gt; field: &lt;code&gt;c&lt;/code&gt; for create, &lt;code&gt;u&lt;/code&gt; for update, &lt;code&gt;d&lt;/code&gt; for delete. A consumer decides what to do with each:&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;@KafkaListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"shop.public.orders"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChangeEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"c"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"u"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;readModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;after&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"d"&lt;/span&gt;      &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;readModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remove&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;before&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="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 coordination with the owning service. No shared library. The consumer subscribes and builds exactly the projection it needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The outbox pattern is CDC with intent
&lt;/h2&gt;

&lt;p&gt;Tailing the whole WAL means downstream consumers see your table exactly as it is. Sometimes that is fine. Sometimes you want to publish a deliberate event, not a raw row.&lt;/p&gt;

&lt;p&gt;The outbox pattern is the same mechanism applied on purpose. Inside the same transaction as your business write, you insert a row into an &lt;code&gt;outbox&lt;/code&gt; table shaped like the event you want the world to see:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&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;'SHIPPED'&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;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'OrderShipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"orderId": 42}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Debezium tails the &lt;code&gt;outbox&lt;/code&gt; table instead of &lt;code&gt;orders&lt;/code&gt;. The event is atomic with the business change because they share one transaction, and its shape is something you designed rather than whatever columns the table happens to have today.&lt;/p&gt;

&lt;p&gt;CDC is the mechanism either way. The outbox just decides what becomes an event and what stays internal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Here is the part nobody puts on the slide.&lt;/p&gt;

&lt;p&gt;Raw-table CDC couples your event stream to the table's physical shape. The moment a downstream consumer maps &lt;code&gt;shop.public.orders&lt;/code&gt;, your column names are a public contract, whether you meant them to be or not.&lt;/p&gt;

&lt;p&gt;That means column renames and other non-backwards-compatible schema changes are off the table for you. Rename &lt;code&gt;status&lt;/code&gt; to &lt;code&gt;order_status&lt;/code&gt; during an internal refactor and every downstream mapper breaks. Silently. There is no compile error, no failed deploy on your side. The consumer just starts reading &lt;code&gt;null&lt;/code&gt; because nobody told it the table changed.&lt;/p&gt;

&lt;p&gt;Tailing the raw WAL is convenient right up until the day your schema is no longer just yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shape the output on purpose
&lt;/h2&gt;

&lt;p&gt;The fix is not avoiding CDC. It is refusing to treat the current table shape as an accident that leaks downstream.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single Message Transforms (SMTs):&lt;/strong&gt; rename, drop, or restructure fields in the connector pipeline, so an internal column rename does not change the published event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A CDC-facing view or outbox table:&lt;/strong&gt; publish from a surface you designed, and let the physical table underneath change freely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A schema registry:&lt;/strong&gt; enforce compatibility rules on the topic, so a breaking change fails loudly at publish time instead of silently downstream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three do the same thing: they put a deliberate boundary between your storage and your stream. The database can still emit every change for free. You just decide what that change looks like on the wire.&lt;/p&gt;

&lt;p&gt;CDC is not "replication with extra steps." It is a coupling decision. Either downstream services couple to your database's physical shape, or they couple to a contract you shaped on purpose. Pick the second one.&lt;/p&gt;

&lt;p&gt;Do you use Debezium to emit events? Do you tail the raw WAL or shape it through an outbox first, and has an internal refactor ever quietly broken a downstream consumer on you?&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>debezium</category>
      <category>postgres</category>
      <category>eventdriven</category>
    </item>
    <item>
      <title>🔁 One Health Check Turned a 10-Minute Outage Into 40</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 06 Jul 2026 18:05:44 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/one-health-check-turned-a-10-minute-outage-into-40-5a9</link>
      <guid>https://dev.to/code_with_kyryl/one-health-check-turned-a-10-minute-outage-into-40-5a9</guid>
      <description>&lt;p&gt;The database went down for ten minutes. The restart storm it triggered lasted forty.&lt;/p&gt;

&lt;p&gt;Every pod's &lt;code&gt;/actuator/health&lt;/code&gt; turned red the moment the database became unreachable. Kubernetes read that as "the process is broken" and started killing pods, one after another, across the entire fleet. New pods booted, connected to nothing, failed the same check, and got killed again. The dependency was down for ten minutes. The self-inflicted damage ran four times longer.&lt;/p&gt;

&lt;p&gt;The trigger was one line of config that looked like a simplification.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that looks clean
&lt;/h2&gt;

&lt;p&gt;Someone wired both the liveness and the readiness probe to the same endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One endpoint, one thing to configure, one thing to reason about. It reads as clean. It is actually a category error, because those two probes are asking completely different questions and Spring's default &lt;code&gt;/actuator/health&lt;/code&gt; answers neither of them cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two probes, two questions
&lt;/h2&gt;

&lt;p&gt;The distinction is the whole point, so it is worth stating flatly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Liveness&lt;/strong&gt; answers: should I kill this process and start a new one? The only honest reason to say yes is that the process is wedged in a way a restart will fix. A deadlock, an unrecoverable internal state, a JVM that is thrashing. Restarting helps only when the problem lives inside the process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Readiness&lt;/strong&gt; answers: should I send this pod traffic right now? A pod can be perfectly alive and still be unable to serve, because something it depends on is unavailable. The fix there is not a restart. It is to stop routing requests until the dependency comes back.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/actuator/health&lt;/code&gt; by default aggregates everything, including the &lt;code&gt;db&lt;/code&gt; health indicator, into one status. Point liveness at it and you have told Kubernetes to kill the process whenever the database is down. But a down database is not a process problem, and no number of restarts will bring it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The restart storm, step by step
&lt;/h2&gt;

&lt;p&gt;Here is the loop that ate thirty extra minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The database becomes unreachable.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;db&lt;/code&gt; health indicator goes &lt;code&gt;DOWN&lt;/code&gt;, so aggregated &lt;code&gt;/actuator/health&lt;/code&gt; returns &lt;code&gt;DOWN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The liveness probe fails. Kubernetes kills the pod.&lt;/li&gt;
&lt;li&gt;A fresh pod boots, starts up, and immediately checks its health.&lt;/li&gt;
&lt;li&gt;The database is still down, so the new pod's health is &lt;code&gt;DOWN&lt;/code&gt; too.&lt;/li&gt;
&lt;li&gt;Liveness fails again. Kubernetes kills it again.&lt;/li&gt;
&lt;li&gt;Repeat, on every pod, for as long as the dependency stays down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now the failure is worse than the original outage. The pods are gone, so even reads that could have been served from cache are gone. Startup load hammers the recovering database the moment it comes back. And your dashboards are a wall of &lt;code&gt;CrashLoopBackOff&lt;/code&gt; that makes it look like the application itself is broken, which sends everyone debugging the wrong thing.&lt;/p&gt;

&lt;p&gt;What should have happened is boring by comparison. Readiness fails, Kubernetes pulls the pods out of the Service endpoints, traffic stops, the processes keep running. When the database returns, readiness goes green and traffic resumes. No restarts, no cold caches, no thundering herd.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix Actuator already ships
&lt;/h2&gt;

&lt;p&gt;You do not need a custom endpoint or a sidecar. Spring Boot Actuator has health groups built for exactly this split.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;health&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;probes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;liveness&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;livenessState&lt;/span&gt;
        &lt;span class="na"&gt;readiness&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;readinessState,db&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you two dedicated endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/actuator/health/liveness&lt;/code&gt; includes only &lt;code&gt;livenessState&lt;/code&gt;, the process-internal signal. No database, no downstream calls. It answers "is this process wedged" and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/actuator/health/readiness&lt;/code&gt; includes &lt;code&gt;readinessState&lt;/code&gt; plus the real dependency checks, &lt;code&gt;db&lt;/code&gt; here. It answers "can this pod serve traffic right now."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Point the probes at the right endpoints:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Now a database outage trips readiness only. Traffic drains, the pods live, and recovery is automatic. Liveness stays green because the process is, in fact, fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;This is not a set-and-forget win, and pretending it is misses the way the bug comes back.&lt;/p&gt;

&lt;p&gt;The config takes five minutes and then nobody looks at it again. That is the problem. Over the next year the service grows: a Redis cache, a Kafka producer, a call to some downstream API. Each of those registers a health indicator, and each is a decision about which group it belongs in. If a new dependency lands in the readiness group, great. If it silently ends up aggregated into liveness, or if someone adds a custom indicator without thinking about probes at all, you have quietly rebuilt the original bug with a new trigger.&lt;/p&gt;

&lt;p&gt;The drift is invisible until the next outage. Nobody audits "which health indicators feed which probe" as part of adding a dependency, so the wrong wiring sits there dormant for months. The next time that specific dependency fails, the restart storm returns, and it looks brand new even though it is the same mistake.&lt;/p&gt;

&lt;p&gt;The mitigation is process, not code: when you add a dependency with a health indicator, decide its group in the same PR. Treat "which probe does this affect" as part of the definition of done for any new external call.&lt;/p&gt;




&lt;p&gt;Has your liveness probe ever quietly grown a dependency check nobody noticed, or has your fleet actually lived through a restart storm like this? Curious how other teams keep the liveness group honest as services accumulate dependencies.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>springboot</category>
      <category>observability</category>
      <category>java</category>
    </item>
    <item>
      <title>🔒 CREATE INDEX Is a Write Outage in Disguise</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 06 Jul 2026 17:55:29 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/create-index-is-a-write-outage-in-disguise-4abo</link>
      <guid>https://dev.to/code_with_kyryl/create-index-is-a-write-outage-in-disguise-4abo</guid>
      <description>&lt;p&gt;CREATE INDEX without CONCURRENTLY locks out writes for the entire build.&lt;/p&gt;

&lt;p&gt;On a large, live table that plain statement is not a migration. It is a multi-minute write outage for every endpoint touching that table. And the worst part is it sails through every test you have, because your test data is tiny and the build finishes before you can blink.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lock nobody reads about
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;CREATE INDEX&lt;/code&gt; acquires a &lt;code&gt;SHARE&lt;/code&gt; lock on the table and holds it until the build completes.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SHARE&lt;/code&gt; allows concurrent reads. It blocks anything that writes: &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;. Every write against that table queues up behind the index build and waits. Not for a moment at the start. For the entire duration.&lt;/p&gt;

&lt;p&gt;On a small table that duration is milliseconds, so nothing queues and nobody notices. That is exactly why this bug survives code review and staging. The table you tested against had ten thousand rows. The table in production has two hundred million, and building the index on it takes four minutes. For those four minutes, every write to &lt;code&gt;orders&lt;/code&gt; is stalled.&lt;/p&gt;

&lt;p&gt;The application does not error. It hangs. Requests pile up, connection pools drain, timeouts cascade to services that never touched the database directly. From the outside it looks like a full outage, and the migration that caused it already "succeeded" in your CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; was built for exactly this.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not take the blocking &lt;code&gt;SHARE&lt;/code&gt; lock. It builds the index in the background, scanning the table while writes keep flowing the whole time. Reads and writes both continue. This is the default you want for any table that is already taking production traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  It is not free
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CONCURRENTLY&lt;/code&gt; trades a fast, clean build for a slower, riskier one. Three costs, all real:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is slower.&lt;/strong&gt; To build without blocking writes, Postgres does two full passes over the table instead of one. One pass to build, a second to catch rows that changed during the first. On a huge table that roughly doubles the build time. You are trading wall-clock time for availability, which is almost always the right trade, but it is a trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It cannot run inside a transaction block.&lt;/strong&gt; This is the one that breaks migration tooling. Most migration frameworks wrap each migration in a transaction by default, and &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; will error out if it finds itself inside one. You cannot bundle it with other DDL in the same atomic step. It has to run on its own, outside a transaction, which means you lose the "all or nothing" guarantee for that migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It can fail into an INVALID index.&lt;/strong&gt; This is the sharp edge. If a &lt;code&gt;CONCURRENTLY&lt;/code&gt; build fails partway, a deadlock, a cancelled session, a statement timeout, Postgres leaves a half-built index behind and marks it &lt;code&gt;INVALID&lt;/code&gt;. It does not roll back. It does not clean up. That index is not used by the planner, but it is still updated on every write and still consumes disk. It just sits there, dead weight, until a human notices.&lt;/p&gt;

&lt;p&gt;You find them by asking the catalog directly:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&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;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;t&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;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indrelid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indisvalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that comes back has to be dropped and rebuilt:&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;DROP&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- then re-run the CREATE INDEX CONCURRENTLY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you run migrations through automation, a failed &lt;code&gt;CONCURRENTLY&lt;/code&gt; will not retry cleanly either, because the invalid index now occupies the name you are trying to create. Your retry fails with "relation already exists" until someone drops the corpse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one real exception
&lt;/h2&gt;

&lt;p&gt;"Always use CONCURRENTLY in production" is a good rule with exactly one honest exception: a brand new, empty table you just created in the same migration.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_audit_log_created_at&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- fine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is writing to &lt;code&gt;audit_log&lt;/code&gt; yet. There are no concurrent writes to block, so the &lt;code&gt;SHARE&lt;/code&gt; lock costs nothing, and the plain build is faster and runs cleanly inside the same transaction as the &lt;code&gt;CREATE TABLE&lt;/code&gt;. Reaching for &lt;code&gt;CONCURRENTLY&lt;/code&gt; here would be cargo-culting the rule past the point where it applies.&lt;/p&gt;

&lt;p&gt;The tell is simple: if the table already has live traffic, use &lt;code&gt;CONCURRENTLY&lt;/code&gt;. If you are indexing something that does not exist yet, do not bother.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CONCURRENTLY&lt;/code&gt; is not a free upgrade you flip on and forget. You trade a guaranteed-fast, guaranteed-clean, transaction-safe build for a slower one that runs outside your transaction and can fail into a mess you have to notice and clean up yourself.&lt;/p&gt;

&lt;p&gt;For any table with real traffic, that trade is worth it every time. A slower build you have to babysit beats a multi-minute outage you cannot. But "worth it" is not "free," and pretending the invalid-index failure mode does not exist is how teams get surprised at 2am.&lt;/p&gt;




&lt;p&gt;Has anyone actually gotten burned by an invalid index left behind after a failed &lt;code&gt;CONCURRENTLY&lt;/code&gt; build? How did you find it, and do you check &lt;code&gt;pg_index&lt;/code&gt; as part of your migration process or only after something breaks?&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>migrations</category>
    </item>
    <item>
      <title>📈 One Path Variable Can Bankrupt Your Prometheus</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 06 Jul 2026 17:30:00 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/one-path-variable-can-bankrupt-your-prometheus-5877</link>
      <guid>https://dev.to/code_with_kyryl/one-path-variable-can-bankrupt-your-prometheus-5877</guid>
      <description>&lt;p&gt;A path variable in your metrics tag will quietly bankrupt your Prometheus backend.&lt;/p&gt;

&lt;p&gt;It passes code review. It compiles. It works, for a while. Then one day the dashboards start timing out, and it takes the team days to trace the slowdown back to a single line of instrumentation that looked completely normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that looks fine
&lt;/h2&gt;

&lt;p&gt;Here is the kind of code that ships this problem. A &lt;code&gt;Timer&lt;/code&gt; around an order lookup, tagged with the &lt;code&gt;orderId&lt;/code&gt; so you can slice latency per request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/orders/{orderId}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Timer&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="s"&gt;"order.lookup"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orderId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- the problem&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing about this fails a review. It reads as "measure how long an order lookup takes, and let me break it down by order." Reasonable intent. The metric even works when you test it locally with three orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;

&lt;p&gt;Prometheus is a time series database. The identity of a time series is its metric name plus the full set of label key-value pairs. Change one label value and you do not add a point to an existing series. You create a brand new series.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;order.lookup{orderId="1001"}&lt;/code&gt; and &lt;code&gt;order.lookup{orderId="1002"}&lt;/code&gt; are two completely separate series, each with its own storage, its own index entry, its own memory footprint.&lt;/p&gt;

&lt;p&gt;Now run that in production. Every distinct &lt;code&gt;orderId&lt;/code&gt; that flows through the endpoint mints a new series. A million orders means a million series from this one metric. Add a &lt;code&gt;userId&lt;/code&gt; tag somewhere else and the counts multiply. This is cardinality explosion, and &lt;code&gt;/actuator/prometheus&lt;/code&gt; will happily expose all of it.&lt;/p&gt;

&lt;p&gt;The failure is gradual, which is what makes it nasty:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Storage grows far faster than your request volume would suggest.&lt;/li&gt;
&lt;li&gt;The Prometheus head block balloons, memory pressure climbs, scrape durations creep up.&lt;/li&gt;
&lt;li&gt;Queries that touch the metric get slow, then time out.&lt;/li&gt;
&lt;li&gt;Someone files "Prometheus is slow" or "the orders dashboard times out."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what is missing from that chain: nobody says "the &lt;code&gt;orderId&lt;/code&gt; tag is the problem." The symptom is three steps removed from the cause. I have watched a team spend the better part of a week bisecting scrape configs and bumping memory limits before someone finally ran a cardinality check and found one metric responsible for millions of series.&lt;/p&gt;

&lt;p&gt;You can catch it directly once you suspect it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# top metrics by series count
topk(10, count by (__name__)({__name__=~".+"}))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But you have to suspect it first, and the whole point is that nothing pointed you there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is not a new tool
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for a sampling library or a relabeling rule to drop the bad label. You do not need either. Spring already hands you the right value.&lt;/p&gt;

&lt;p&gt;For every request, Spring resolves the matched route pattern, the same mechanism its built-in HTTP server metrics (&lt;code&gt;http.server.requests&lt;/code&gt;) use to keep their &lt;code&gt;uri&lt;/code&gt; tag bounded. The best-matching pattern is available on the request as an attribute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/orders/{orderId}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;HandlerMapping&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BEST_MATCHING_PATTERN_ATTRIBUTE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "/api/orders/{orderId}"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order.lookup"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"route"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// bounded to the number of routes&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the tag value is &lt;code&gt;/api/orders/{orderId}&lt;/code&gt;, the template, not the resolved id. Every request through this endpoint lands on the same series. Cardinality for this metric is bounded to the number of routes you have, which is a small, fixed number that does not grow with traffic.&lt;/p&gt;

&lt;p&gt;If all you wanted was per-endpoint latency, you may not need a custom timer at all. &lt;code&gt;http.server.requests&lt;/code&gt; already gives you templated URI, method, and status out of the box. Reach for a custom metric only when you need a dimension the built-in one does not expose, and when you do, tag it with something bounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Templating the URI collapses per-entity granularity in the metric itself. That is a real loss, not a footnote.&lt;/p&gt;

&lt;p&gt;Once the tag is &lt;code&gt;/api/orders/{orderId}&lt;/code&gt;, the metric can no longer answer "how slow was the lookup for order 1002 specifically." It can only tell you about the route as a whole: p50, p99, error rate across all orders.&lt;/p&gt;

&lt;p&gt;If you genuinely need to investigate one entity, that is now a logs or traces question, not a metrics question. Attach the &lt;code&gt;orderId&lt;/code&gt; to a span or a structured log field, where high cardinality is expected and the backend is built for it. Metrics are for bounded, aggregatable dimensions. Traces and logs are for the long tail of individual cases.&lt;/p&gt;

&lt;p&gt;That split is the right one. Metrics answer "is the system healthy and how is this route trending." Traces answer "what happened to this one request." Putting a unique id in a metric tag is asking metrics to do the job of tracing, and the bill for that mistake is paid by your storage backend, quietly, until it is not quiet anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Any tag value that is not drawn from a small, known set does not belong on a metric. User ids, order ids, request ids, email addresses, raw paths: all of them are cardinality bombs. Route patterns, status codes, HTTP methods, enum-like states: all fine.&lt;/p&gt;

&lt;p&gt;The tell is simple. Before you add a tag, ask how many distinct values it can take over the life of the service. If the answer scales with your traffic, you have found the bug before it finds you.&lt;/p&gt;




&lt;p&gt;Has your Prometheus setup ever fallen over from a label nobody flagged, and how long did it take to trace it back to the metric? Curious how other teams caught it, and what guardrails you put in place afterward.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>java</category>
      <category>observability</category>
      <category>prometheus</category>
    </item>
    <item>
      <title>"🚩 readOnly = true Is Not a Comment"</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Fri, 03 Jul 2026 18:54:38 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/-readonly-true-is-not-a-comment-4cki</link>
      <guid>https://dev.to/code_with_kyryl/-readonly-true-is-not-a-comment-4cki</guid>
      <description>&lt;p&gt;&lt;code&gt;@Transactional(readOnly = true)&lt;/code&gt; gets slapped on every query method out of habit, the way people sprinkle &lt;code&gt;final&lt;/code&gt; on local variables. A marker for the next reader, a bit of documentation that says "this method does not write anything." Except it is not documentation. It is a flag that Hibernate and, if you have set it up, your Postgres routing &lt;code&gt;DataSource&lt;/code&gt; both actually read and act on.&lt;/p&gt;

&lt;p&gt;Ignore that and you get one of two failures. Either a write silently vanishes with no exception, or every read in your app hits the primary database because nothing ever told the driver it was allowed to go to a replica.&lt;/p&gt;

&lt;h2&gt;
  
  
  What people think readOnly = true does
&lt;/h2&gt;

&lt;p&gt;The common mental model: it is a hint for whoever reads the code later, maybe a small optimization Spring does under the hood, nothing that changes behavior you would notice. Under that model, marking a method &lt;code&gt;readOnly = true&lt;/code&gt; when it is not strictly read-only feels harmless. Worst case, a wasted annotation.&lt;/p&gt;

&lt;p&gt;That model is wrong on the part that matters most: the flush mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does: changes Hibernate's flush mode
&lt;/h2&gt;

&lt;p&gt;Inside a transaction marked &lt;code&gt;readOnly = true&lt;/code&gt;, Spring propagates that flag down to the underlying Hibernate &lt;code&gt;Session&lt;/code&gt; and sets its flush mode to manual. Normally, Hibernate auto-flushes: before you run a query, it checks the persistence context for dirty managed entities and writes them out first, so the query sees consistent state. Under manual flush mode, none of that happens.&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="nc"&gt;Report&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Hibernate sets FlushMode.MANUAL here&lt;/span&gt;
    &lt;span class="c1"&gt;// no dirty-checking, no auto-flush&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a genuinely read-only method, this is a pure win. Skipping dirty-checking on every entity in the persistence context is real overhead, and &lt;code&gt;readOnly = true&lt;/code&gt; removes it. That is the entire justification for slapping it on query methods, and it is a good one, right up until someone mutates state inside that method.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: a write inside a read-only transaction
&lt;/h2&gt;

&lt;p&gt;Here is the version that gets shipped:&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="c1"&gt;// "just documentation", right?&lt;/span&gt;
&lt;span class="nc"&gt;Report&lt;/span&gt; &lt;span class="nf"&gt;loadAndPatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Report&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setLastViewedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// looks persisted&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ...it never flushes. The update vanishes.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;r&lt;/code&gt; is a managed entity. &lt;code&gt;setLastViewedAt&lt;/code&gt; mutates its field. Every instinct built from normal Spring Data usage says this change will be picked up on flush, the way it would in any other &lt;code&gt;@Transactional&lt;/code&gt; method. It will not. The flush mode is manual, nothing triggers a flush, and the transaction commits with the mutation sitting in memory and nowhere else.&lt;/p&gt;

&lt;p&gt;No exception. Nothing in the logs. The method returns a &lt;code&gt;Report&lt;/code&gt; object with the field looking correctly set, because the in-memory object really was mutated. Only the database was never told. The bug surfaces days or weeks later as "last viewed timestamps are not updating," and the first three places anyone looks are the query, the column mapping, and the transaction boundary. The annotation that caused it reads like the most innocent line in the method.&lt;/p&gt;

&lt;p&gt;This is worse than a typo in a query. A typo throws. This just quietly does nothing, forever, until someone notices the data is stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: it can route your read to a replica
&lt;/h2&gt;

&lt;p&gt;The flush-mode change is reason enough to take &lt;code&gt;readOnly = true&lt;/code&gt; seriously, but on a Postgres setup with read replicas it does a second job. If you have wired an &lt;code&gt;AbstractRoutingDataSource&lt;/code&gt; that inspects the current transaction's read-only flag, that flag is the actual signal deciding which physical database the query hits:&lt;br&gt;
&lt;/p&gt;

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

    &lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;determineCurrentLookupKey&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;ro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransactionSynchronizationManager&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isCurrentTransactionReadOnly&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;ro&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"replica"&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"primary"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TransactionSynchronizationManager.isCurrentTransactionReadOnly()&lt;/code&gt; returns exactly the value Spring set from your &lt;code&gt;@Transactional(readOnly = true)&lt;/code&gt; annotation. Get the annotation right and your reads spread across replicas, taking load off the primary. Get it wrong, forget it on a genuinely read-only method, and that query hits the primary for no reason. Put it on a method that writes, and depending on your routing setup you can end up sending a write-carrying transaction at a replica connection that rejects writes outright, or worse, one that does not reject them and you get an inconsistency between primary and replica state.&lt;/p&gt;

&lt;p&gt;The annotation is not decoration in either direction. It is the single signal two separate systems, your ORM's flush behavior and your connection routing, both key off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Mislabeling a write path as &lt;code&gt;readOnly = true&lt;/code&gt; fails silently, not loudly. That is the real cost here, and it is worse than it sounds. A method that should compile-error or throw at runtime instead just does nothing to the database, and the visible parts of the system, the returned object, the lack of any exception, all look correct. You do not get a stack trace pointing at the problem. You get a support ticket three weeks later asking why a field never updates.&lt;/p&gt;

&lt;p&gt;The fix is not to avoid &lt;code&gt;readOnly = true&lt;/code&gt;. It genuinely helps, both for the flush-mode overhead and for replica routing. The fix is discipline: only mark a method &lt;code&gt;readOnly = true&lt;/code&gt; when you have actually checked that it never mutates a managed entity, directly or through a called method. Treat the annotation as a contract, not a habit. If a method's purpose changes later and someone adds a write to it, the missing exception means nobody will be warned. Code review is the only real defense, along with grep-ing for &lt;code&gt;readOnly = true&lt;/code&gt; methods that call setters on entities pulled from a repository.&lt;/p&gt;

&lt;p&gt;Have you tracked a "why is this not saving" bug back to a stray &lt;code&gt;readOnly = true&lt;/code&gt;? What caught it, a test, a code review, or production data going stale?&lt;/p&gt;

</description>
      <category>java</category>
      <category>spring</category>
      <category>hibernate</category>
      <category>postgres</category>
    </item>
    <item>
      <title>"🔇 Calling Your Own @Async Method Does Nothing"</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Fri, 03 Jul 2026 18:54:03 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/-calling-your-own-async-method-does-nothing-3aho</link>
      <guid>https://dev.to/code_with_kyryl/-calling-your-own-async-method-does-nothing-3aho</guid>
      <description>&lt;p&gt;Called &lt;code&gt;this.notify(o)&lt;/code&gt; from inside &lt;code&gt;placeOrder()&lt;/code&gt;, both methods on the same &lt;code&gt;@Service&lt;/code&gt;. No exception. No warning. It just ran the mailer call inline, blocking the request thread like &lt;code&gt;@Async&lt;/code&gt; was never there.&lt;/p&gt;

&lt;p&gt;Here is the setup that broke:&lt;br&gt;
&lt;/p&gt;

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

    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// bypasses the proxy&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Async&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mailer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// never actually async&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;Nothing here looks wrong on a first read. &lt;code&gt;notify()&lt;/code&gt; is annotated &lt;code&gt;@Async&lt;/code&gt;, it lives on a &lt;code&gt;@Service&lt;/code&gt;, the class is wired into the container correctly. And yet &lt;code&gt;placeOrder()&lt;/code&gt; blocks on &lt;code&gt;mailer.send()&lt;/code&gt; every single time. No thread pool, no exception, no log line telling you the annotation got ignored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this.method() Skips the Proxy
&lt;/h2&gt;

&lt;p&gt;Spring AOP does not rewrite your class. When you annotate a method with &lt;code&gt;@Async&lt;/code&gt;, &lt;code&gt;@Transactional&lt;/code&gt;, or &lt;code&gt;@Cacheable&lt;/code&gt;, Spring wraps the &lt;em&gt;bean&lt;/em&gt; in a proxy: a JDK dynamic proxy if the bean implements an interface, a CGLIB subclass otherwise. Every advice you rely on, the async executor handoff, the transaction interceptor, the cache lookup, lives on that proxy, not on your class.&lt;/p&gt;

&lt;p&gt;When another bean calls &lt;code&gt;orderService.notify(o)&lt;/code&gt;, it is calling the proxy. The proxy runs its advice, then delegates to the real method. That is the whole mechanism working as designed.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;placeOrder()&lt;/code&gt; calls &lt;code&gt;this.notify(o)&lt;/code&gt;, there is no proxy in the picture. &lt;code&gt;this&lt;/code&gt; inside a Spring-managed object is the raw, unproxied instance. It was never wrapped, because Spring only ever gets a chance to wrap the object other beans see when they ask the container for it. The object refers to itself directly, and Java resolves &lt;code&gt;this.notify(o)&lt;/code&gt; as an ordinary virtual method call. Spring cannot intercept a call it never sees, because the call never leaves the object.&lt;/p&gt;

&lt;p&gt;This is not a bug in Spring. It is a direct consequence of how proxy-based AOP works, and it applies to every annotation-driven aspect Spring ships: &lt;code&gt;@Async&lt;/code&gt;, &lt;code&gt;@Transactional&lt;/code&gt;, &lt;code&gt;@Cacheable&lt;/code&gt;, &lt;code&gt;@Retryable&lt;/code&gt;, custom &lt;code&gt;@Aspect&lt;/code&gt; advice, all of it. Self-invocation quietly skips every one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Split the Callee Into a Second Bean
&lt;/h2&gt;

&lt;p&gt;The textbook answer is to make sure the call always crosses a real bean boundary. Pull the annotated method out into its own class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NotifyService&lt;/span&gt; &lt;span class="n"&gt;notifyService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;notifyService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// real proxy&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotifyService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Async&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mailer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// now actually async&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 works. &lt;code&gt;notifyService&lt;/code&gt; is a container-managed reference, Spring hands &lt;code&gt;OrderService&lt;/code&gt; the proxy, and the call to &lt;code&gt;notify()&lt;/code&gt; goes through it like any other cross-bean call. &lt;code&gt;@Async&lt;/code&gt; fires, &lt;code&gt;mailer.send()&lt;/code&gt; runs on the executor, &lt;code&gt;placeOrder()&lt;/code&gt; returns immediately.&lt;/p&gt;

&lt;p&gt;The cost is a class that has no reason to exist except to dodge this one behavior. &lt;code&gt;NotifyService&lt;/code&gt; is not a domain concept. It does not group related responsibilities, it does not hide an implementation detail worth hiding, it exists purely because Spring's proxy model requires a bean boundary between caller and callee. Every time someone reads the codebase and asks "why is notification logic split out into its own service", the honest answer is "so a self-invocation bug does not resurface", which is not an answer that belongs in a design review.&lt;/p&gt;

&lt;p&gt;For a genuinely separate concern, this split is the right call regardless of the AOP issue. For a single method that happens to need &lt;code&gt;@Async&lt;/code&gt;, it is architecture shaped by a framework limitation, not by the domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: Inject the Bean Into Itself
&lt;/h2&gt;

&lt;p&gt;The pragmatic fix keeps the method where it belongs and instead gets a real proxy reference inside the class:&lt;br&gt;
&lt;/p&gt;

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

    &lt;span class="nd"&gt;@Lazy&lt;/span&gt; &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// proxy to itself&lt;/span&gt;

    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// through the proxy, for real&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Async&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mailer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;self&lt;/code&gt; is a field of the bean's own type, autowired like any other dependency. Spring resolves it to the actual proxy, the same object every other bean gets when it asks the container for an &lt;code&gt;OrderService&lt;/code&gt;. Calling &lt;code&gt;self.notify(o)&lt;/code&gt; instead of &lt;code&gt;this.notify(o)&lt;/code&gt; sends the call through that proxy, the async advice runs, and &lt;code&gt;mailer.send()&lt;/code&gt; finally executes on the executor instead of the caller's thread.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;@Lazy&lt;/code&gt; is not optional. Without it, Spring tries to fully construct &lt;code&gt;OrderService&lt;/code&gt;, including resolving its &lt;code&gt;self&lt;/code&gt; field, which means it needs a fully constructed &lt;code&gt;OrderService&lt;/code&gt; to finish constructing &lt;code&gt;OrderService&lt;/code&gt;. That is a circular dependency, and Spring fails at startup rather than silently accepting it. &lt;code&gt;@Lazy&lt;/code&gt; tells Spring to inject a lazy proxy for &lt;code&gt;self&lt;/code&gt; instead, one that only resolves the real bean the first time &lt;code&gt;self.notify()&lt;/code&gt; (or any method on it) actually gets called, well after the container has finished wiring everything else. By then &lt;code&gt;OrderService&lt;/code&gt; exists, the cycle never has to resolve eagerly, and startup succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Trade-off
&lt;/h2&gt;

&lt;p&gt;Self-injection needs &lt;code&gt;@Lazy&lt;/code&gt; to avoid that circular-dependency failure, which means the fix does not work by just adding a field, it works by adding a field plus an annotation whose job is to explain to Spring why injecting a bean into itself is not actually circular. Anyone unfamiliar with the trick reads &lt;code&gt;@Lazy @Autowired private OrderService self;&lt;/code&gt; and reasonably assumes something is wrong with the code, because nothing about "inject the class into itself" looks intentional on a first pass.&lt;/p&gt;

&lt;p&gt;It is still the better trade. Splitting &lt;code&gt;notify()&lt;/code&gt; into &lt;code&gt;NotifyService&lt;/code&gt; solves the same problem by growing the class hierarchy, and that growth has to be maintained forever: another file, another bean to wire in tests, another indirection for anyone tracing the call path, all to work around a framework detail that has nothing to do with what the domain actually looks like. The self-injected field is uglier at the point you read it, but it stays contained to the one class that needs it, and it does not force a design decision that only exists because of AOP.&lt;/p&gt;

&lt;p&gt;Neither fix is free. Pick the one whose cost you would rather explain in a code review: a class that exists for no domain reason, or a field that looks wrong until someone tells you why it is there.&lt;/p&gt;

&lt;p&gt;Ever shipped a silently-skipped &lt;code&gt;@Async&lt;/code&gt; or &lt;code&gt;@Transactional&lt;/code&gt; to production because of self-invocation? What finally gave it away?&lt;/p&gt;

</description>
      <category>java</category>
      <category>spring</category>
      <category>springboot</category>
      <category>aop</category>
    </item>
    <item>
      <title>"🥊 Your Retry Aspect Is Retrying a Dead Transaction"</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Fri, 03 Jul 2026 18:53:28 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/-your-retry-aspect-is-retrying-a-dead-transaction-4kbf</link>
      <guid>https://dev.to/code_with_kyryl/-your-retry-aspect-is-retrying-a-dead-transaction-4kbf</guid>
      <description>&lt;p&gt;A retry aspect and a &lt;code&gt;@Transactional&lt;/code&gt; aspect were both wrapping the same method. Neither had &lt;code&gt;@Order&lt;/code&gt;. I assumed Spring would nest them the way I had pictured in my head. It did not, and the failure mode was a &lt;code&gt;UnexpectedRollbackException&lt;/code&gt; on a method that should have quietly succeeded on the third attempt.&lt;/p&gt;

&lt;p&gt;Here is the setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RetryAspect&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Around&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@annotation(Retryable)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProceedingJoinPoint&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// retry loop around pjp.proceed()&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TxAspect&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Around&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@annotation(Transactional)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;wrap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProceedingJoinPoint&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;proceed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// no @Order on either one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both aspects match the same method through two different annotations. Nothing in that code tells Spring which one should run first. Spring AOP will still pick something, deterministically for a given build, but that something is not documented, not guaranteed across versions, and not something you chose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nesting order matters here
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;@Around&lt;/code&gt; advice wraps the method call. When two advices target the same join point, they nest like Russian dolls: the outer one runs first, calls into the inner one, which calls into the actual method. Which aspect ends up outer decides what the inner one is running inside of.&lt;/p&gt;

&lt;p&gt;For a retry aspect and a transaction aspect, that is not a cosmetic detail. It decides whether "retry" means "retry the whole transaction" or "retry inside a transaction that has already failed once."&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong order: transaction outer, retry inner
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Order&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="c1"&gt;// OUTER: opens the tx first&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TxAspect&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Around&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@annotation(Transactional)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;wrap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProceedingJoinPoint&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;proceed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// INNER: retries land in that tx&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RetryAspect&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Around&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@annotation(Retryable)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProceedingJoinPoint&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// retry loop around pjp.proceed()&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;Lower &lt;code&gt;@Order&lt;/code&gt; values run first on the way in, which puts &lt;code&gt;TxAspect&lt;/code&gt; on the outside. One transaction opens before the retry loop even starts, and every retry attempt runs inside that same transaction.&lt;/p&gt;

&lt;p&gt;That is the bug. The moment the first attempt throws whatever transient exception the retry logic is supposed to swallow, Spring's transaction machinery marks the current transaction rollback-only. That flag does not clear on the next attempt. It cannot, it is the same transaction. So attempt two runs inside a transaction that is already condemned. It might even succeed on its own terms, the business logic completes fine, but when the retry aspect's &lt;code&gt;@Around&lt;/code&gt; unwinds and the transaction tries to commit, it hits the rollback-only flag and throws &lt;code&gt;UnexpectedRollbackException&lt;/code&gt; instead of committing anything.&lt;/p&gt;

&lt;p&gt;From the caller's side this looks insane. The logs show the operation succeeding on retry, then the whole thing still fails. The retry loop did its job. The transaction it was retrying inside of was already dead before the second attempt began.&lt;/p&gt;

&lt;h2&gt;
  
  
  Right order: retry outer, transaction inner
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Order&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="c1"&gt;// OUTER: retries first&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RetryAspect&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Around&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@annotation(Retryable)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProceedingJoinPoint&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// retry loop around pjp.proceed()&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// INNER: fresh tx per attempt&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TxAspect&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Around&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@annotation(Transactional)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;wrap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProceedingJoinPoint&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;proceed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="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;Swap the &lt;code&gt;@Order&lt;/code&gt; values and the nesting flips. &lt;code&gt;RetryAspect&lt;/code&gt; is now outer, so its retry loop calls into &lt;code&gt;TxAspect&lt;/code&gt; fresh on every attempt. &lt;code&gt;TxAspect&lt;/code&gt; opens a brand-new transaction each time it is invoked, because from its point of view each retry is a completely separate call. Attempt one fails and rolls back cleanly. Attempt two starts an unmarked, unrelated transaction and gets a real shot at succeeding. No transaction ever carries scar tissue from a previous attempt.&lt;/p&gt;

&lt;p&gt;This is the nesting you actually want for "retry a transactional operation": each attempt is its own unit of work, committed or rolled back on its own, with no memory of the attempt before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;A fresh transaction per retry attempt is not free. Each one is a new database round trip: begin, do the work, commit or roll back. If your retry policy allows five attempts, a failing call can now open five transactions instead of one, and depending on your isolation level and connection pool size, that adds real latency and real contention under load.&lt;/p&gt;

&lt;p&gt;That cost is worth paying for genuinely transient failures: a deadlock victim getting picked, a connection reset mid-query, a lock wait timeout. Those are cases where the exact same operation, run again a moment later, plausibly succeeds because the condition that broke it was temporary.&lt;/p&gt;

&lt;p&gt;It is not worth paying for a bug. If the method fails because of bad input, a null somewhere it should not be, a constraint violation that is always going to violate, retrying it just runs the same doomed logic multiple times against a fresh transaction each time, burning connections and latency for a result that was never going to change. Retry policies need to be scoped to exceptions that are actually retryable, not &lt;code&gt;Exception.class&lt;/code&gt; as a catch-all, or this whole ordering fix just gives you a more expensive way to fail five times instead of once.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@Order&lt;/code&gt; is not decoration on these two annotations. It is the only thing that decides whether "retry" means a clean second attempt or a slow-motion replay inside a transaction that already gave up.&lt;/p&gt;

&lt;p&gt;Have you hit an aspect ordering bug like this, and how did you end up debugging it back to &lt;code&gt;@Order&lt;/code&gt;?&lt;/p&gt;

</description>
      <category>java</category>
      <category>spring</category>
      <category>springboot</category>
      <category>aop</category>
    </item>
    <item>
      <title>"🔁 Your Prototype Bean Is a Singleton in Disguise"</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Fri, 03 Jul 2026 18:52:46 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/-your-prototype-bean-is-a-singleton-in-disguise-cni</link>
      <guid>https://dev.to/code_with_kyryl/-your-prototype-bean-is-a-singleton-in-disguise-cni</guid>
      <description>&lt;p&gt;A prototype-scoped bean injected into a singleton is created exactly once. That is not a corner case, it is how Spring's dependency injection works by design, and it catches people who assume &lt;code&gt;@Scope("prototype")&lt;/code&gt; means "new instance whenever someone asks."&lt;/p&gt;

&lt;p&gt;Here is the setup that trips people up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@Scope&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"prototype"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TokenGenerator&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;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;next&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;seed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nanoTime&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportService&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;TokenGenerator&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;ReportService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TokenGenerator&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// resolved ONCE, right here, when the container wires ReportService&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;gen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gen&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="nf"&gt;generate&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;gen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ReportService&lt;/code&gt; is a singleton. Spring builds it once at startup, and to build it, it has to resolve the &lt;code&gt;TokenGenerator&lt;/code&gt; constructor argument. That resolution happens exactly one time. The prototype scope on &lt;code&gt;TokenGenerator&lt;/code&gt; never gets a second chance to do its job, because nothing ever asks the container for another one. &lt;code&gt;ReportService&lt;/code&gt; just holds onto the first instance it got, for the rest of the application's life.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;TokenGenerator&lt;/code&gt; were stateless, nobody would ever notice. The bug shows up the moment the prototype bean carries state that is supposed to reset per use, a running total, a per-request seed, a cache that should not outlive one call. Then every "fresh" instance is actually the same object, and callers start stepping on each other's state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: ApplicationContext.getBean() — it works, but it is ugly
&lt;/h2&gt;

&lt;p&gt;The instinctive patch is to stop injecting the bean directly and instead ask the container for it on demand:&lt;br&gt;
&lt;/p&gt;

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

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;ApplicationContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;TokenGenerator&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TokenGenerator&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&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 does fix the bug. Every call to &lt;code&gt;generate()&lt;/code&gt; pulls a brand-new &lt;code&gt;TokenGenerator&lt;/code&gt; from the container, because &lt;code&gt;getBean()&lt;/code&gt; triggers scope resolution every time it runs, not just at wiring time.&lt;/p&gt;

&lt;p&gt;The problem is what it costs you. &lt;code&gt;ReportService&lt;/code&gt; now holds a live reference to the whole &lt;code&gt;ApplicationContext&lt;/code&gt;, which means it can reach any bean in the application, not just the one it needs. Unit testing gets worse too: instead of mocking one collaborator, you have to mock the entire context or stand up a real one. And the lookup is stringly typed by class, which means a typo or a refactor that renames the bean fails at runtime, not at compile time. It works. It also does not belong in application code that has any other option.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: an abstract @Lookup method — cleaner, still Spring-flavored
&lt;/h2&gt;

&lt;p&gt;Spring has a purpose-built mechanism for exactly this case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Lookup&lt;/span&gt;
    &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nc"&gt;TokenGenerator&lt;/span&gt; &lt;span class="nf"&gt;gen&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;generate&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;gen&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@Lookup&lt;/code&gt; tells Spring to generate a subclass of &lt;code&gt;ReportService&lt;/code&gt; at runtime (via CGLIB) that overrides &lt;code&gt;gen()&lt;/code&gt; to fetch a fresh &lt;code&gt;TokenGenerator&lt;/code&gt; from the container every time it is called. Your code never touches &lt;code&gt;ApplicationContext&lt;/code&gt; directly, never does a stringly-typed &lt;code&gt;getBean()&lt;/code&gt; call, and the method signature documents exactly what type it returns.&lt;/p&gt;

&lt;p&gt;The catch: &lt;code&gt;ReportService&lt;/code&gt; and the &lt;code&gt;gen()&lt;/code&gt; method cannot be &lt;code&gt;final&lt;/code&gt;, because CGLIB needs to subclass them. Constructor injection for other dependencies still works fine alongside &lt;code&gt;@Lookup&lt;/code&gt;, but the class itself has to stay proxyable. It is still coupled to Spring, just through an annotation instead of an API call, and it depends on a runtime-generated subclass existing, which occasionally surprises people debugging stack traces for the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: inject ObjectProvider — the one to actually reach for
&lt;/h2&gt;

&lt;p&gt;The cleanest option skips both the container reference and the CGLIB proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportService&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;ObjectProvider&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TokenGenerator&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;genProvider&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;ReportService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ObjectProvider&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TokenGenerator&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;genProvider&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;genProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;genProvider&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="nf"&gt;generate&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;TokenGenerator&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;genProvider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getObject&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;gen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ObjectProvider&amp;lt;T&amp;gt;&lt;/code&gt; is a plain constructor-injected dependency, no &lt;code&gt;ApplicationContext&lt;/code&gt;, no abstract class, no CGLIB subclass. Calling &lt;code&gt;.getObject()&lt;/code&gt; resolves a fresh prototype bean at that exact moment, and only at that moment. The rest of &lt;code&gt;ReportService&lt;/code&gt; stays a normal, final, easily-mocked class. If you are on &lt;code&gt;jakarta.inject&lt;/code&gt;, &lt;code&gt;Provider&amp;lt;T&amp;gt;&lt;/code&gt; gives you the same behavior with a one-method interface, if you prefer not to depend on a Spring-specific type at all.&lt;/p&gt;

&lt;p&gt;This is also the version that is easiest to test. Mock &lt;code&gt;ObjectProvider&amp;lt;TokenGenerator&amp;gt;&lt;/code&gt; to return a stub &lt;code&gt;TokenGenerator&lt;/code&gt; from &lt;code&gt;getObject()&lt;/code&gt;, and &lt;code&gt;ReportService&lt;/code&gt; never needs a real Spring context in the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;None of these three fixes matter if &lt;code&gt;TokenGenerator&lt;/code&gt; is stateless. A stateless prototype bean behaves identically to a singleton, so field-injecting it directly is not a bug, it is just an unnecessary scope declaration. Reach for &lt;code&gt;ObjectProvider&lt;/code&gt; only when the prototype bean genuinely carries per-call state that would otherwise leak between callers. Adding indirection to fetch a bean that never needed to be re-created is its own kind of mistake, just a quieter one.&lt;/p&gt;

&lt;p&gt;There is a real cost either way: &lt;code&gt;ObjectProvider&lt;/code&gt; (or &lt;code&gt;@Lookup&lt;/code&gt;, or &lt;code&gt;getBean()&lt;/code&gt;) adds a layer of indirection that a reader has to understand before they see why the bean is not just constructor-injected like everything else. That indirection earns its keep exactly when the state leak is real, and nowhere else.&lt;/p&gt;

&lt;p&gt;Have you shipped the &lt;code&gt;ApplicationContext.getBean()&lt;/code&gt; version to production before catching it in review? What made you notice?&lt;/p&gt;

</description>
      <category>java</category>
      <category>spring</category>
      <category>springboot</category>
      <category>di</category>
    </item>
    <item>
      <title>🧱 Your Test Fixtures Are Lying About What Matters</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Fri, 03 Jul 2026 11:51:05 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-test-fixtures-are-lying-about-what-matters-i2k</link>
      <guid>https://dev.to/code_with_kyryl/your-test-fixtures-are-lying-about-what-matters-i2k</guid>
      <description>&lt;p&gt;Open a test file with a thirty-argument constructor call and try to spot the one value the test actually cares about. You cannot do it at a glance. Every field looks equally important, because the constructor treats them that way.&lt;/p&gt;

&lt;p&gt;Here is the domain object in question, a fairly ordinary &lt;code&gt;User&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="no"&gt;UUID&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;email&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;firstName&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;lastName&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;phone&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;Address&lt;/span&gt; &lt;span class="n"&gt;address&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;Role&lt;/span&gt; &lt;span class="n"&gt;role&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;AccountStatus&lt;/span&gt; &lt;span class="n"&gt;status&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="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;emailVerified&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="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;twoFactorEnabled&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;sessionToken&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;Instant&lt;/span&gt; &lt;span class="n"&gt;tokenIssuedAt&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;Instant&lt;/span&gt; &lt;span class="n"&gt;createdAt&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;Instant&lt;/span&gt; &lt;span class="n"&gt;lastLoginAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ... 16 more fields exactly like this&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&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;email&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;firstName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;lastName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Address&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AccountStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;emailVerified&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;twoFactorEnabled&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;sessionToken&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;tokenIssuedAt&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;lastLoginAt&lt;/span&gt;
                &lt;span class="cm"&gt;/* ...16 more parameters */&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// assign everything&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;Now here is a test that exercises exactly one behavior: a session with an expired token gets rejected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;expiredTokenIsRejected&lt;/span&gt;&lt;span class="o"&gt;()&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="s"&gt;"jane.doe@example.com"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"Jane"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"Doe"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"+1-555-0100"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Address&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"221B Baker St"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"London"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"NW1"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UK"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CUSTOMER&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;AccountStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACTIVE&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="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"expired-token-abc123"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// &amp;lt;- the one value this test cares about&lt;/span&gt;
        &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;minus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofDays&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt;
        &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="kc"&gt;null&lt;/span&gt;
        &lt;span class="c1"&gt;// ... 16 more arguments exactly like this&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isValid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;isFalse&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 fact under test, a token issued thirty days ago, sits at position eleven out of thirty. A reviewer has to read the whole argument list to find it, and count commas to be sure they found the right one. Six months from now, when &lt;code&gt;User&lt;/code&gt; grows a &lt;code&gt;preferredLanguage&lt;/code&gt; field, every one of these constructor calls either breaks or silently gets a &lt;code&gt;null&lt;/code&gt; nobody reviewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Nobody designs a test this way on purpose. It happens because the constructor is the only tool available, and the constructor's job is to build a fully valid object, not to communicate what a specific test is checking. The constructor is honest about the shape of &lt;code&gt;User&lt;/code&gt;. It says nothing about which of those thirty values is the point.&lt;/p&gt;

&lt;p&gt;That is the actual problem, and it is not verbosity. It is signal-to-noise. A fixture constructor forces every test to restate the entire object graph, every time, whether that test cares about eviction dates, marketing consent, or nothing but a single boolean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;A test data builder inverts the default. It ships with sensible, valid values for everything, and exposes named methods for the one or two things a given test needs to override.&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;UserTestDataBuilder&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&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;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"jane.doe@example.com"&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;firstName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Jane"&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;lastName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Doe"&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;Role&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CUSTOMER&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;AccountStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AccountStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACTIVE&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;sessionToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"valid-token"&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;Instant&lt;/span&gt; &lt;span class="n"&gt;tokenIssuedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// ... every other field, defaulted to something valid&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;UserTestDataBuilder&lt;/span&gt; &lt;span class="nf"&gt;aUser&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UserTestDataBuilder&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;UserTestDataBuilder&lt;/span&gt; &lt;span class="nf"&gt;withExpiredToken&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;sessionToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"expired-token-abc123"&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;tokenIssuedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;minus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofDays&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&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;User&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firstName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lastName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="n"&gt;sessionToken&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokenIssuedAt&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="cm"&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;And the test collapses to 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;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;expiredTokenIsRejected&lt;/span&gt;&lt;span class="o"&gt;()&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="n"&gt;aUser&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;withExpiredToken&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="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isValid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;isFalse&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;Everything except the token is defaulted. The reader does not scan fourteen positional arguments looking for the relevant one, they read &lt;code&gt;withExpiredToken()&lt;/code&gt; and move on. When &lt;code&gt;User&lt;/code&gt; gains a &lt;code&gt;preferredLanguage&lt;/code&gt; field next sprint, it gets a default inside the builder once, and every existing test keeps compiling, untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;This is not free. Someone has to write the builder class, and someone has to keep its defaults sensible as the domain object evolves. That is a second place &lt;code&gt;User&lt;/code&gt; lives, and it can drift out of sync with real constraints if nobody updates it when validation rules change.&lt;/p&gt;

&lt;p&gt;There is a subtler cost too. A "just valid enough" default can silently satisfy a constraint the test author never thought about. If &lt;code&gt;AccountStatus.ACTIVE&lt;/code&gt; is the builder's default and a bug only reproduces for &lt;code&gt;AccountStatus.PENDING_VERIFICATION&lt;/code&gt;, the builder hides that gap exactly as effectively as a thirty-argument constructor call hides the one field that matters. Defaults reduce noise, they do not replace judgment about what a test should actually cover.&lt;/p&gt;

&lt;p&gt;For a domain object with three or four fields, none of this is worth it, a plain constructor is fine. It starts paying for itself once an object crosses somewhere around ten to fifteen fields, or once it shows up in more than a handful of tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually gain
&lt;/h2&gt;

&lt;p&gt;Reviewers read intent, not positions. New fields do not break unrelated tests. And the one thing under test is loud, instead of buried in a wall of arguments a reviewer has learned to skim past, which is its own kind of risk: skimmed code is where the real bug in position eleven goes unnoticed.&lt;/p&gt;

&lt;p&gt;What do you reach for once your domain objects outgrow a plain constructor call: an Object Mother, a builder like the one above, or a random test-data library like Instancio or EasyRandom? Curious what actually holds up at scale versus what looks good in a blog post.&lt;/p&gt;

</description>
      <category>java</category>
      <category>testing</category>
      <category>springboot</category>
      <category>cleancode</category>
    </item>
    <item>
      <title>"🧪 Good Enough Tests Died When AI Started Writing Them"</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Tue, 30 Jun 2026 19:16:56 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/-good-enough-tests-died-when-ai-started-writing-them-1l2</link>
      <guid>https://dev.to/code_with_kyryl/-good-enough-tests-died-when-ai-started-writing-them-1l2</guid>
      <description>&lt;p&gt;For years, integration tests got a quiet pass. "Good enough, not perfect" was an acceptable answer, because writing them by hand was expensive and somebody had to ship the feature. That excuse just expired. AI writes these tests now, the authoring cost collapsed, and the bar moves from good enough to exact.&lt;/p&gt;

&lt;p&gt;Before the how, the where.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is about the middle of the pyramid
&lt;/h2&gt;

&lt;p&gt;Three layers, and this post is only about one of them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit tests&lt;/strong&gt; sit at the bottom. Fast, pure, no infrastructure. Not the topic here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration tests&lt;/strong&gt; sit in the middle. Your code against real infrastructure and mocked externals. This is the layer everyone gets wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End-to-end tests&lt;/strong&gt; sit at the top. A thin layer that hits the real vendor sandbox and proves the whole thing actually works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5j0l765qp5zigmx29wu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5j0l765qp5zigmx29wu.png" alt="The test pyramid: a wide Unit base, Integration highlighted in the middle, and a narrow E2E tip" width="800" height="554"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Almost nobody does the middle layer properly. Teams fall into one of two traps. They mock everything, so the suite stays green while production breaks, because the mocks drifted and the tests only proved their own assumptions. Or they skip the middle entirely and lean on a handful of e2e tests to somehow cover the gap. A disciplined integration layer is rare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: split by ownership
&lt;/h2&gt;

&lt;p&gt;The fix is not "mock or real". It is one question asked per dependency: &lt;strong&gt;do I own this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ownership decides fidelity. If you own it, run it for real. If you do not, mock it at the wire. That single cut resolves almost every "should I mock this" argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infra you own: run it for real, at the prod version
&lt;/h2&gt;

&lt;p&gt;Your database, your message broker, your cache, your sibling services. Run them in containers that match production. Not H2 standing in for Postgres. Not an embedded broker. The real engine.&lt;/p&gt;

&lt;p&gt;Two ways to get there. &lt;strong&gt;Testcontainers&lt;/strong&gt; spins real containers from inside the test:&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;@Container&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;db&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;PostgreSQLContainer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:16.3"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nd"&gt;@Container&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;KafkaContainer&lt;/span&gt; &lt;span class="n"&gt;kafka&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;KafkaContainer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"apache/kafka:3.8.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or a &lt;strong&gt;docker-compose&lt;/strong&gt; that brings the whole stack up once and lets the suite run against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16.3&lt;/span&gt;          &lt;span class="c1"&gt;# match prod exactly&lt;/span&gt;
  &lt;span class="na"&gt;kafka&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apache/kafka:3.8.0&lt;/span&gt;     &lt;span class="c1"&gt;# KRaft, no ZooKeeper&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_PROCESS_ROLES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;broker,controller&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are valid. Testcontainers gives you per-test lifecycle and zero shared state. Compose gives you one warm stack and faster local loops. Pick per project.&lt;/p&gt;

&lt;p&gt;The part people skip is the &lt;strong&gt;version&lt;/strong&gt;. Matching the engine is not enough, you have to match the version. Production runs Kafka in KRaft mode, and the test suite still runs Kafka plus ZooKeeper from a template somebody copied in 2021. That drift used to be tolerable. It is not anymore. The behavior, the configs, the failure modes differ. Pin the test image to what production runs, and bump it when production bumps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third-party API you do not own: WireMock the wire
&lt;/h2&gt;

&lt;p&gt;For the external HTTP API you do not control, mock it at the wire. WireMock is the tool (MockServer works too). It stands up a fake HTTP server, so you stub the response and verify the call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;stubFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/charges"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;willReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;okJson&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{ \"id\": \"ch_1\" }"&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;

&lt;span class="c1"&gt;// your real client runs against the fake server&lt;/span&gt;

&lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postRequestedFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/charges"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withRequestBody&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matchingJsonPath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$.amount"&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical word is &lt;strong&gt;wire&lt;/strong&gt;. Stub the wire, not your Java client. The moment you mock your own client class, you mock away serialization, the retry policy, timeouts, and error mapping. That is exactly the code most likely to carry the bug. WireMock leaves all of it running and only fakes the server on the other end. And in the AI-era bar, every outbound call gets both a stub and a &lt;code&gt;verify&lt;/code&gt;, so a silently-dropped or malformed request fails the test instead of slipping through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safety net: a wire mock can lie
&lt;/h2&gt;

&lt;p&gt;A stub is a snapshot. The day the vendor changes their contract, your mock keeps returning the old shape and your green suite is now fiction. This is the real cost of mocking, and pretending otherwise is dishonest.&lt;/p&gt;

&lt;p&gt;That is precisely the job of the thin e2e-against-sandbox layer at the top of the pyramid. It runs against the vendor's real sandbox, out of band from the main suite, and catches the drift the mock cannot. Back it with recorded real responses or contract tests so the stubs stay honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;AI collapsed the cost of &lt;strong&gt;writing&lt;/strong&gt; these tests. It did not collapse the cost of &lt;strong&gt;running&lt;/strong&gt; them.&lt;/p&gt;

&lt;p&gt;You still pay for Docker in CI and the startup seconds each container costs, which you mitigate with container reuse, singletons, or a compose stack that comes up once. You now owe version upkeep every time production upgrades, because a pinned image that drifts behind prod is its own quiet lie. And the wire mocks still rot, so the e2e-on-sandbox layer is not optional.&lt;/p&gt;

&lt;p&gt;The bar is higher because the labor is finally cheap. The runtime and maintenance bill is still real, and you should budget for it instead of pretending the AI made testing free.&lt;/p&gt;




&lt;p&gt;Now that AI writes the tests, is "good enough" coverage still an acceptable answer? And has a lying mock or a version mismatch ever shipped a bug past your integration suite?&lt;/p&gt;

</description>
      <category>testing</category>
      <category>java</category>
      <category>testcontainers</category>
      <category>springboot</category>
    </item>
  </channel>
</rss>
