<?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>"🚩 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>
    <item>
      <title>🗄️ The JPA Enum Default Quietly Corrupts Your Data</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Mon, 29 Jun 2026 18:48:13 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/u0001f5c4-the-jpa-enum-default-quietly-corrupts-your-data-20pe</link>
      <guid>https://dev.to/code_with_kyryl/u0001f5c4-the-jpa-enum-default-quietly-corrupts-your-data-20pe</guid>
      <description>&lt;p&gt;You add an enum to an entity, slap &lt;code&gt;@Enumerated&lt;/code&gt; on it, and move on. Five seconds. It is the kind of decision nobody writes a design doc for.&lt;/p&gt;

&lt;p&gt;Then six months later a row comes back as &lt;code&gt;SHIPPED&lt;/code&gt; when it was &lt;code&gt;PAID&lt;/code&gt;, no exception was thrown, no query failed, and you spend an afternoon learning that the default you never thought about has been silently rewriting history.&lt;/p&gt;

&lt;p&gt;Here is the order lifecycle we will use the whole way through:&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;enum&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;PAID&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;SHIPPED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;DELIVERED&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five ways to store it. They are not equivalent, and the gap between them only shows up under change.&lt;/p&gt;

&lt;h2&gt;
  
  
  @Enumerated(ORDINAL): store the position
&lt;/h2&gt;

&lt;p&gt;This is the default. Leave the annotation bare and JPA stores the enum's ordinal, its index in the declaration order.&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;@Enumerated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnumType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ORDINAL&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;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PENDING&lt;/code&gt; is 0, &lt;code&gt;PAID&lt;/code&gt; is 1, &lt;code&gt;SHIPPED&lt;/code&gt; is 2, &lt;code&gt;DELIVERED&lt;/code&gt; is 3. The column is a tidy little &lt;code&gt;smallint&lt;/code&gt;. Everything works.&lt;/p&gt;

&lt;p&gt;Until someone needs a new status and adds it where it reads well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;PAID&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;CANCELLED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// inserted here&lt;/span&gt;
    &lt;span class="no"&gt;SHIPPED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;DELIVERED&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;CANCELLED&lt;/code&gt; is now 2. &lt;code&gt;SHIPPED&lt;/code&gt; is 3. &lt;code&gt;DELIVERED&lt;/code&gt; is 4. Every row written before this change still holds the old integer, so every order that was &lt;code&gt;SHIPPED&lt;/code&gt; (2) now reads back as &lt;code&gt;CANCELLED&lt;/code&gt;. The database is correct. Your data is wrong. And nothing told you.&lt;/p&gt;

&lt;p&gt;If you are stuck with ORDINAL on a legacy schema, pin it with a test that fails the build the moment someone reorders:&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;ordinalsAreFrozen&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ordinal&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&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="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PAID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ordinal&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SHIPPED&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ordinal&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DELIVERED&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ordinal&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;New constants may only be appended. The test turns an invisible runtime corruption into a loud compile-time-ish failure. It is a guardrail, not a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  @Enumerated(STRING): store the name
&lt;/h2&gt;

&lt;p&gt;Store the constant name instead of its position.&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;@Enumerated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnumType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STRING&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;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the column holds &lt;code&gt;'PAID'&lt;/code&gt;. Reordering the enum is free, because the name does not move when the position does. The column is readable in a raw &lt;code&gt;SELECT&lt;/code&gt;, exports document themselves, and a human debugging production can actually tell what a row means.&lt;/p&gt;

&lt;p&gt;The cost moves to renames. Rename the constant:&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="no"&gt;PAID&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;SETTLED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and every row still says &lt;code&gt;'PAID'&lt;/code&gt;, which no longer matches any constant. Reads blow up or silently drop, depending on your mapping. A rename now requires a data 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;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;'SETTLED'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PAID'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For most schemas this is the right default. Renames are rarer than reorders, and when they happen they are at least visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real Postgres enum type
&lt;/h2&gt;

&lt;p&gt;The two options above are enforced entirely in the JPA layer. The database sees a &lt;code&gt;varchar&lt;/code&gt; and will happily accept &lt;code&gt;'BANANA'&lt;/code&gt;. If you want the database itself to guarantee validity, give it a real type:&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;TYPE&lt;/span&gt; &lt;span class="n"&gt;order_status&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;ENUM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PAID'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SHIPPED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DELIVERED'&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;TABLE&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;id&lt;/span&gt;          &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;      &lt;span class="n"&gt;order_status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now an invalid status is rejected at insert time, by the database, no matter which application or migration script tries to write it. Pair it with &lt;code&gt;@Enumerated(STRING)&lt;/code&gt; on the Java side so the names line up.&lt;/p&gt;

&lt;p&gt;One JDBC detail bites everyone the first time: the Postgres driver sends strings as &lt;code&gt;varchar&lt;/code&gt;, and Postgres will not implicitly cast &lt;code&gt;varchar&lt;/code&gt; to the enum type. Add &lt;code&gt;stringtype=unspecified&lt;/code&gt; to the connection URL and the cast resolves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jdbc:postgresql://localhost:5432/app?stringtype=unspecified
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The limit: Postgres only lets you append values to an enum type. &lt;code&gt;ALTER TYPE ... ADD VALUE&lt;/code&gt; works, but reordering or dropping a value means recreating the type and rewriting every column that uses it. You traded application-side flexibility for database-side guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  AttributeConverter: decouple the name from the stored value
&lt;/h2&gt;

&lt;p&gt;Both STRING and native enums tie the stored value to the Java identifier. Rename the constant, migrate the data. If you want renames to be free, stop storing the name at all. Store a stable code instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PND"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;PAID&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PAID"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;SHIPPED&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHP"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;DELIVERED&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DLV"&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;code&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;code&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getCode&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;code&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="nf"&gt;fromCode&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;code&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&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;s&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unknown code: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Converter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autoApply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderStatusConverter&lt;/span&gt;
        &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AttributeConverter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;convertToDatabaseColumn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCode&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="nf"&gt;convertToEntityAttribute&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;code&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;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromCode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The column stores &lt;code&gt;'PND'&lt;/code&gt;. The Java identifier is now free to change:&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="no"&gt;PENDING&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;AWAITING_PAYMENT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code stays &lt;code&gt;"PND"&lt;/code&gt;, the database does not move, the rename is a pure refactor your IDE does in one shortcut. The price is one extra class per enum and a layer of indirection: the code in the column no longer reads like the constant, so a raw &lt;code&gt;SELECT&lt;/code&gt; shows &lt;code&gt;'PND'&lt;/code&gt; instead of &lt;code&gt;'PENDING'&lt;/code&gt;. You bought rename-freedom with a little readability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lookup table
&lt;/h2&gt;

&lt;p&gt;The last option stops treating status as an enum at all. It becomes a foreign key to a reference table.&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;order_status&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt;        &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt;       &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sort_order&lt;/span&gt;  &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_active&lt;/span&gt;   &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;true&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;TABLE&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;id&lt;/span&gt;          &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status_id&lt;/span&gt;   &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;order_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the one people reach for too early. It looks like the "grown-up" option, so it gets used as a general way to dodge renames and schema changes. That is the wrong reason. You pay a join on every read, you lose compile-time exhaustiveness (the compiler can no longer warn you about an unhandled &lt;code&gt;switch&lt;/code&gt; branch), and you take on the overhead of keeping a relationship in sync.&lt;/p&gt;

&lt;p&gt;A lookup table earns all of that only when the value carries editable business data. A display label the product team changes without a deploy. A sort order. A colour for the UI. A feature flag or an SLA attached to the status. Data that lives and changes at runtime.&lt;/p&gt;

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

&lt;p&gt;There is no free option here, and the lookup table is where the cost is steepest. Every status read becomes a join. Every &lt;code&gt;switch&lt;/code&gt; over status loses the safety net that makes enums worth using in Java. And you now own a tiny CRUD surface for reference data that, in most systems, never actually changes.&lt;/p&gt;

&lt;p&gt;Weigh that against what STRING plus a Postgres enum type costs: a one-time migration on the rare day you rename a constant. For the overwhelming majority of status columns, that is the cheaper bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;Default to &lt;code&gt;@Enumerated(STRING)&lt;/code&gt; backed by a native Postgres enum type. You get readable columns, reorder-safety, and the database rejecting garbage at the door.&lt;/p&gt;

&lt;p&gt;Upgrade to an &lt;code&gt;AttributeConverter&lt;/code&gt; the moment renames need to be free, for example a domain vocabulary that is still settling and gets renamed often.&lt;/p&gt;

&lt;p&gt;Reach for a lookup table only when the value is genuinely a record: it has a label, a flag, an SLA, something a human edits at runtime.&lt;/p&gt;

&lt;p&gt;That is the whole decision, compressed: if the value is identity only, keep it an enum. If the value is a record, it was never really an enum.&lt;/p&gt;

&lt;p&gt;Get that right once and your statuses stay boring, which is the highest compliment you can pay a column.&lt;/p&gt;




&lt;p&gt;What do you store status enums as in production, and has a reorder or a rename ever burned you?&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>postgres</category>
      <category>jpa</category>
    </item>
    <item>
      <title>🌐 HTTP Got a New Verb, and Your POST /search Was Always a Lie</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Sun, 28 Jun 2026 19:36:14 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/http-got-a-new-verb-and-your-post-search-was-always-a-lie-1ba5</link>
      <guid>https://dev.to/code_with_kyryl/http-got-a-new-verb-and-your-post-search-was-always-a-lie-1ba5</guid>
      <description>&lt;p&gt;Every &lt;code&gt;POST /search&lt;/code&gt; endpoint you have ever written is a read pretending to be a write.&lt;/p&gt;

&lt;p&gt;You did not do it because you wanted to. You did it because the alternatives were worse. Your search has a dozen filters, some of them nested, a few of them arrays, and there is no sane way to stuff all of that into a URL. So you reached for POST, put the filter in the body, and moved on. It works.&lt;/p&gt;

&lt;p&gt;But you also lied to every proxy, cache, and retry layer between the client and your server. You told them this request might change state. It does not. It is a safe, idempotent read, and you dressed it up as a mutation because the protocol gave you no better option.&lt;/p&gt;

&lt;p&gt;As of late 2025, the protocol gives you a better option. It is called QUERY.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new HTTP method, which almost never happens
&lt;/h2&gt;

&lt;p&gt;HTTP methods do not change often. Most developers carry a mental list that has not moved in their entire career: GET, POST, PUT, PATCH, DELETE, plus HEAD and OPTIONS if you are being thorough. PATCH was the last meaningful addition, standardized as RFC 5789 in 2010.&lt;/p&gt;

&lt;p&gt;QUERY is the next one. The IESG approved "The HTTP QUERY Method" as a Proposed Standard on 2025-11-20, published as &lt;a href="https://www.rfc-editor.org/rfc/rfc10008.html" rel="noopener noreferrer"&gt;RFC 10008&lt;/a&gt;. It started life as the IETF draft with the very on-the-nose name &lt;code&gt;draft-ietf-httpbis-safe-method-w-body&lt;/code&gt;: a safe method, with a body. That name is the whole idea.&lt;/p&gt;

&lt;p&gt;If you are wondering why you are reading about a late-2025 RFC in the middle of 2026: standards land long before tooling does. The draft churned from 2021 to 2025, the RFC is only months old, and servers, proxies, and client libraries are just now starting to react. The spec is the starting gun, not the finish line, which is exactly why the conversation is happening now rather than the day it was published.&lt;/p&gt;

&lt;p&gt;One more point of confusion worth clearing up: this is a single new method, QUERY, and it is not the same as SEARCH. WebDAV defined a &lt;code&gt;SEARCH&lt;/code&gt; verb back in 2008 (&lt;a href="https://www.rfc-editor.org/rfc/rfc5323.html" rel="noopener noreferrer"&gt;RFC 5323&lt;/a&gt;), built around a generic XML body and a pile of WebDAV semantics most people never wanted. The early drafts of this very spec were actually titled "HTTP SEARCH Method," then the working group renamed it to QUERY to escape that WebDAV baggage and to better signal its relationship to the URI's query component. So QUERY is the modern, format-agnostic successor to SEARCH, not a second new verb alongside it.&lt;/p&gt;

&lt;p&gt;QUERY is exactly what it sounds like once you frame it right: &lt;strong&gt;GET's guarantees with POST's request body.&lt;/strong&gt; It is safe and idempotent, but it is allowed to carry content, and the content plus its &lt;code&gt;Content-Type&lt;/code&gt; defines the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  The search-endpoint dilemma
&lt;/h2&gt;

&lt;p&gt;To see why this matters, look at the three options you have today for a non-trivial search endpoint. All three are compromises.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: GET with a giant query string
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;GET&lt;/span&gt; &lt;span class="nn"&gt;/contacts?select=surname,givenname,email&amp;amp;filter[country]=UA&amp;amp;filter[tags][]=vip&amp;amp;filter[tags][]=lead&amp;amp;sort=-createdAt&amp;amp;limit=10&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is semantically perfect. GET is safe, idempotent, and cacheable, which is exactly what a search is. It works beautifully right up until the query gets complicated.&lt;/p&gt;

&lt;p&gt;Then the problems start. URLs have practical length limits, usually somewhere between 2KB and 8KB depending on the browser, proxy, and server in the chain, and a rich filter blows past them faster than you would expect. There is no standard way to represent nested objects or arrays in a query string, so every framework invents its own &lt;code&gt;filter[tags][]&lt;/code&gt; dialect. Everything is percent-encoded into noise. And those URLs land in access logs and browser history, filters and all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: GET with a request body
&lt;/h3&gt;

&lt;p&gt;You might think: fine, keep the GET semantics, just move the filter into the body. The spec has historically said a GET body has "no defined semantics," and in practice that is a minefield. Some proxies and servers reject the request. Some silently drop the body before your handler ever sees it. Some pass it through. You cannot rely on any of it. This is the one option that is simply not safe to ship.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 3: POST /search
&lt;/h3&gt;

&lt;p&gt;So you do what everyone does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/contacts/search&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&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="s2"&gt;"vip"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lead"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&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;This works everywhere. The body holds the full filter with real JSON structure. No URL limits, no encoding mess.&lt;/p&gt;

&lt;p&gt;The cost is semantic. POST is defined as neither safe nor idempotent. That has real consequences beyond pedantry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retries are off the table.&lt;/strong&gt; A proxy, gateway, or HTTP client that will happily retry a failed GET will refuse to retry a POST, because retrying a POST might create a second resource. Your read, which is perfectly safe to repeat, is treated as dangerous.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching basically does not happen.&lt;/strong&gt; POST responses are cacheable only under narrow, rarely-implemented conditions. In practice your search results are uncacheable at every layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intermediaries lose the plot.&lt;/strong&gt; Every proxy, WAF, and observability tool in the path sees a POST and assumes a write. None of them can reason about your read as a read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You have a safe idempotent operation that the entire HTTP stack treats as a mutation. That is the lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  How QUERY fixes it
&lt;/h2&gt;

&lt;p&gt;QUERY gives you the POST body without the POST semantics. Here is the canonical shape, straight out of the RFC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;QUERY&lt;/span&gt; &lt;span class="nn"&gt;/contacts&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example.org&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/x-www-form-urlencoded&lt;/span&gt;
&lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

select=surname,givenname,email&amp;amp;limit=10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&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;"surname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Smith"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"givenname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"John"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"smith@example.org"&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;The body can be anything with a media type. Form-encoded, JSON, a GraphQL document, your own filter DSL. The &lt;code&gt;Content-Type&lt;/code&gt; tells the server how to parse it, and a &lt;code&gt;200 OK&lt;/code&gt; returns the results in the response body. Because the method is declared safe and idempotent, every caching and retry mechanism that works for GET is allowed to work here too.&lt;/p&gt;

&lt;p&gt;The RFC is also strict about error signaling, which makes QUERY endpoints pleasant to integrate against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missing &lt;code&gt;Content-Type&lt;/code&gt;: &lt;code&gt;400 Bad Request&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Unsupported media type: &lt;code&gt;415 Unsupported Media Type&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Body inconsistent with its declared &lt;code&gt;Content-Type&lt;/code&gt;: &lt;code&gt;400 Bad Request&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Syntactically valid query that fails semantically: &lt;code&gt;422 Unprocessable Content&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Cannot produce the format the client asked for in &lt;code&gt;Accept&lt;/code&gt;: &lt;code&gt;406 Not Acceptable&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part people miss: Content-Location
&lt;/h2&gt;

&lt;p&gt;QUERY is more than "POST that promises to behave." The detail that makes it interesting is the &lt;code&gt;Content-Location&lt;/code&gt; response header.&lt;/p&gt;

&lt;p&gt;A QUERY response can include &lt;code&gt;Content-Location&lt;/code&gt; pointing at a URI that represents the results of that operation. The client can then issue a plain &lt;code&gt;GET&lt;/code&gt; against that URI to retrieve the same results again. So a single QUERY can hand you back a cacheable, bookmarkable, shareable GET URL for a result set that was too complex to express as a GET in the first place. The expensive structured filter goes over QUERY once; the cheap stable link works as an ordinary GET afterward.&lt;/p&gt;

&lt;p&gt;And QUERY responses are genuinely cacheable, with one rule that explains the whole design: &lt;strong&gt;the cache key must incorporate the request body, not just the method and URL.&lt;/strong&gt; Two QUERY requests to the same path with different bodies are different queries with different results. This is also precisely why QUERY had to be a new method rather than a blessing of "GET with a body." The entire HTTP caching model keys on method plus URL. Teaching caches to also key on the body is a real semantic change, and it needed a verb of its own to hang that behavior on safely.&lt;/p&gt;

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

&lt;p&gt;Here is the part where I stop selling it.&lt;/p&gt;

&lt;p&gt;You should not migrate anything to QUERY tomorrow. The standard is months old, and adoption is the bottleneck. Origin servers, reverse proxies, CDNs, API gateways, browser fetch stacks, and HTTP client libraries largely do not understand QUERY yet. A browser &lt;code&gt;fetch()&lt;/code&gt; will let you send an arbitrary method string, but you have no guarantee the proxies and load balancers between you and the origin will pass it through instead of choking on a verb they do not recognize.&lt;/p&gt;

&lt;p&gt;This is not hypothetical pessimism, it is just how HTTP adoption works. PATCH was standardized in 2010 and still took years before you could assume a random framework and gateway handled it without special-casing. QUERY is at the very start of that same curve.&lt;/p&gt;

&lt;p&gt;So the pragmatic position for production in 2026 is unchanged: &lt;strong&gt;&lt;code&gt;POST /search&lt;/code&gt; is still the right default.&lt;/strong&gt; It works on every piece of infrastructure you will ever deploy behind.&lt;/p&gt;

&lt;p&gt;What changes is how you think about that endpoint. You now know it is a workaround, not the correct expression of what you are doing. QUERY is the thing to learn now, prototype on internal services where you control the whole path, and reach for the moment your stack and your proxies actually support it. The win it offers is not a feature you are currently missing. It is semantic honesty: a way to finally tell the HTTP stack the truth about an operation that was always a safe, idempotent read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;QUERY is GET's guarantees plus POST's body, and it exists because every backend developer independently arrived at the same hack and the standard finally caught up to it. Your search endpoints were never really writes. Now there is a verb that agrees with you, even if the rest of the internet needs a few years to catch up.&lt;/p&gt;




&lt;p&gt;What do you reach for on a heavy search endpoint today: &lt;code&gt;POST /search&lt;/code&gt;, a sprawling query string, GraphQL, something else? And once your gateways and clients support it, would you actually switch to QUERY, or is &lt;code&gt;POST /search&lt;/code&gt; too entrenched to bother? Real-world setups beat theory here.&lt;br&gt;
&lt;/p&gt;

</description>
      <category>http</category>
      <category>webdev</category>
      <category>backend</category>
      <category>api</category>
    </item>
    <item>
      <title>🧹 Your Spring Service Does Not Need That Interface</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Thu, 25 Jun 2026 21:09:05 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-spring-service-does-not-need-that-interface-241k</link>
      <guid>https://dev.to/code_with_kyryl/your-spring-service-does-not-need-that-interface-241k</guid>
      <description>&lt;p&gt;&lt;code&gt;UserService&lt;/code&gt;. &lt;code&gt;UserServiceImpl&lt;/code&gt;. One interface, one implementation, and there will only ever be one. Open any Spring codebase and you will find dozens of these pairs. Nobody on the team remembers deciding to do it this way. It is just how services are written.&lt;/p&gt;

&lt;p&gt;Here is the part most people never learned: the reason this pattern exists stopped being true around Spring Boot 2.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the interface used to be mandatory
&lt;/h2&gt;

&lt;p&gt;Go back to early Spring and EJB. When you put &lt;code&gt;@Transactional&lt;/code&gt; on a bean, Spring did not run your method directly. It wrapped your bean in a proxy, and the proxy opened the transaction, called your method, then committed or rolled back.&lt;/p&gt;

&lt;p&gt;The default proxy mechanism was the JDK dynamic proxy. And JDK dynamic proxies have one hard rule: they can only proxy interfaces. The proxy is a synthetic class that implements your interface and delegates to the real object. No interface, no proxy.&lt;/p&gt;

&lt;p&gt;So if you wanted &lt;code&gt;@Transactional&lt;/code&gt;, &lt;code&gt;@Async&lt;/code&gt;, &lt;code&gt;@Cacheable&lt;/code&gt;, or any other AOP-driven annotation on a service, you needed that service to implement an interface. It was not a design decision about abstraction or testability. It was a mechanical requirement of the framework. The &lt;code&gt;Impl&lt;/code&gt; class was the price of admission.&lt;/p&gt;

&lt;p&gt;That is where the muscle memory came from. A whole generation of Spring developers learned "services have interfaces" as a rule, without the context that it was a workaround for a proxy limitation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint is gone
&lt;/h2&gt;

&lt;p&gt;Spring Boot flipped the default to CGLIB proxies. Since Boot 2.0, &lt;code&gt;proxyTargetClass&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; out of the box. CGLIB does not implement an interface, it generates a runtime subclass of your concrete class and overrides each method to add the advice.&lt;/p&gt;

&lt;p&gt;That means &lt;code&gt;@Transactional&lt;/code&gt; works perfectly on a plain 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;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserService&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;UserRepository&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;UserService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="n"&gt;userRepository&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;userRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readOnly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&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;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CreateUserRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&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="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 interface. CGLIB subclasses &lt;code&gt;UserService&lt;/code&gt; at startup, the transactional advice still runs, and everything behaves exactly as it did with the interface in place. The framework reason for the interface evaporated, but the habit did not.&lt;/p&gt;

&lt;p&gt;Compare that to what the "proper" version used to look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;UserService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CreateUserRequest&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="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserServiceImpl&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;UserService&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;UserRepository&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;UserServiceImpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="n"&gt;userRepository&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;userRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readOnly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CreateUserRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&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="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;Twice the files. The method signatures written out twice. An &lt;code&gt;@Override&lt;/code&gt; on everything. And not a single new behavior to show for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But I need the interface to mock it"
&lt;/h2&gt;

&lt;p&gt;This is the second defense, and it is just as dead as the first.&lt;/p&gt;

&lt;p&gt;Mockito has mocked concrete classes for years. It generates a subclass at runtime, the same trick CGLIB uses. You do not need an interface to write a test double:&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;@ExtendWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MockitoExtension&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderServiceTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Mock&lt;/span&gt;
    &lt;span class="nc"&gt;UserService&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// concrete class, mocked fine&lt;/span&gt;

    &lt;span class="nd"&gt;@InjectMocks&lt;/span&gt;
    &lt;span class="nc"&gt;OrderService&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;rejects_order_for_missing_user&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42L&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thenThrow&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;UserNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42L&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="n"&gt;assertThrows&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderRejectedException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="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;place&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42L&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cart&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same goes for &lt;code&gt;@MockBean&lt;/code&gt; in a slice test. Spring replaces the bean with a mock whether the type is an interface or a class. "Interface for testability" describes a world that ended a long time ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;Impl&lt;/code&gt; suffix is the tell
&lt;/h2&gt;

&lt;p&gt;When the only way to name your implementation is to staple &lt;code&gt;Impl&lt;/code&gt; onto the interface name, that is the code telling you the interface is not doing anything. A good abstraction has a name that means something on its own. &lt;code&gt;PaymentProvider&lt;/code&gt; and &lt;code&gt;StripePaymentProvider&lt;/code&gt;. &lt;code&gt;Cache&lt;/code&gt; and &lt;code&gt;RedisCache&lt;/code&gt;. The interface describes a role, the implementation describes one concrete way to fill it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;UserService&lt;/code&gt; and &lt;code&gt;UserServiceImpl&lt;/code&gt; describe the same thing twice. It is one concept wearing two files. And you pay for the split constantly: every "go to definition" lands on the interface, and you click through to the impl to see what the code actually does. Multiply that by every service, every navigation, every new hire trying to read the codebase.&lt;/p&gt;

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

&lt;p&gt;This is not "interfaces are bad." That would be a dumb position. Interfaces are one of the most useful tools in the language, and there are clear places where a service interface earns every line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Genuinely multiple implementations.&lt;/strong&gt; A &lt;code&gt;PaymentProvider&lt;/code&gt; with Stripe and PayPal behind it. A &lt;code&gt;NotificationChannel&lt;/code&gt; for email, SMS, and push. The interface is the abstraction over real variants, and a strategy pattern needs it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A module or published-API boundary.&lt;/strong&gt; When other modules or services consume your type, the interface is the contract. You expose the interface and hide the implementation so you can change internals without breaking callers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ports and adapters / hexagonal.&lt;/strong&gt; The port is an interface on purpose. It keeps your domain code from depending on infrastructure. The &lt;code&gt;UserRepository&lt;/code&gt; interface in your domain, implemented by a JPA adapter in your infrastructure layer, is exactly right.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction is the number of implementations and the existence of a real seam, not the layer the class happens to live in. A service interface at an architectural boundary is doing work. A service interface wrapped around a single CRUD bean in the same package is ceremony.&lt;/p&gt;

&lt;p&gt;There is also an asymmetry that settles the default. Extracting an interface later, when a second implementation actually arrives, is a 10-second IDE refactor: right-click, "Extract Interface," done. Carrying a dead interface on every service from day one is a cost you pay forever, on every file and every navigation. Cheap to add when you need it, expensive to maintain when you do not. That asymmetry says: default to the concrete class.&lt;/p&gt;

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

&lt;p&gt;Start with the concrete class. Put &lt;code&gt;@Transactional&lt;/code&gt; right on it. Let CGLIB do its job. The moment you have a second implementation or a real cross-module boundary, extract the interface then, and give it a name that means something. Do not predict the second implementation. In most services it never comes.&lt;/p&gt;

&lt;p&gt;What looked like clean architecture was mostly inertia from a constraint that expired eight years ago.&lt;/p&gt;




&lt;p&gt;Do you still write an interface for every service, or did you drop the habit? If you kept it, what is the rule that makes it worth the second file for you? Real-world approaches beat theory here.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>architecture</category>
      <category>cleancode</category>
    </item>
    <item>
      <title>Your @EventListener Fires Before the Transaction Commits⚙️</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Wed, 24 Jun 2026 21:20:49 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-eventlistener-fires-before-the-transaction-commits-286m</link>
      <guid>https://dev.to/code_with_kyryl/your-eventlistener-fires-before-the-transaction-commits-286m</guid>
      <description>&lt;p&gt;Your domain event fires. Your notification service queries the DB for the entity that just got saved. It finds nothing.&lt;/p&gt;

&lt;p&gt;You add a log line. It starts working. You remove the log. It breaks again.&lt;/p&gt;

&lt;p&gt;That's not a race condition. That's &lt;code&gt;@EventListener&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually happening
&lt;/h2&gt;

&lt;p&gt;Spring's &lt;code&gt;@EventListener&lt;/code&gt; fires synchronously, inside the calling thread, before the transaction commits. The DB row exists in Hibernate's session — but it hasn't been flushed and committed yet. Other connections, including the one your listener opens when it calls &lt;code&gt;findById&lt;/code&gt;, can't see it.&lt;/p&gt;

&lt;p&gt;The log statement "fixes" it because the delay gives Hibernate time to flush. Remove the log, the flush doesn't happen in time, and you're back to an empty &lt;code&gt;Optional&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the broken 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;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderEventListener&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@EventListener&lt;/span&gt; &lt;span class="c1"&gt;// fires MID-TRANSACTION, before commit&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;onOrderCreated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderCreatedEvent&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="c1"&gt;// Transaction not committed yet.&lt;/span&gt;
        &lt;span class="c1"&gt;// Other DB connections see nothing.&lt;/span&gt;
        &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrderId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ← throws here, row doesn't exist yet&lt;/span&gt;

        &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notifyCustomer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fm2ea41kmwp186fo65us6.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%2Fm2ea41kmwp186fo65us6.png" alt="The problem: @EventListener fires mid-transaction, before commit" width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix and what it costs you
&lt;/h2&gt;

&lt;p&gt;Spring ships &lt;code&gt;@TransactionalEventListener&lt;/code&gt; for exactly this. Set &lt;code&gt;phase = TransactionPhase.AFTER_COMMIT&lt;/code&gt; and the listener fires after the transaction commits. The row is visible. &lt;code&gt;findById&lt;/code&gt; returns the order. Problem solved.&lt;br&gt;
&lt;/p&gt;

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

    &lt;span class="nd"&gt;@TransactionalEventListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransactionPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AFTER_COMMIT&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;onOrderCreated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderCreatedEvent&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="c1"&gt;// Transaction committed. All connections see the row.&lt;/span&gt;
        &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrderId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ← works fine&lt;/span&gt;

        &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notifyCustomer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F1qcd7oieuygmdw77a8pv.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%2F1qcd7oieuygmdw77a8pv.png" alt="The fix: @TransactionalEventListener fires after commit" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But the trade-off is real. Your listener is now decoupled from the transaction. If the listener fails — notification service is down, the email throws, the external API times out — the transaction already committed. The event is gone. Nothing retries it. Nothing tells you it was dropped.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@EventListener&lt;/code&gt;: stale reads.&lt;br&gt;
&lt;code&gt;@TransactionalEventListener(AFTER_COMMIT)&lt;/code&gt;: silent data loss on listener failure.&lt;/p&gt;

&lt;p&gt;Neither is great.&lt;/p&gt;
&lt;h2&gt;
  
  
  The edge case that bites in tests
&lt;/h2&gt;

&lt;p&gt;There's a second problem with &lt;code&gt;@TransactionalEventListener&lt;/code&gt; that most teams hit in tests or Kafka consumers: if there's no active transaction, the listener silently does nothing.&lt;/p&gt;

&lt;p&gt;Call the service from a unit test without &lt;code&gt;@Transactional&lt;/code&gt;. Publish a Kafka message that triggers the same service method without a transaction boundary. The listener won't fire. No warning. No exception. The event just disappears.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;fallbackExecution = true&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@TransactionalEventListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransactionPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AFTER_COMMIT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fallbackExecution&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;// fires even with no active transaction&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onOrderCreated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderCreatedEvent&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="c1"&gt;// Now works from Kafka consumers, tests, scheduled tasks&lt;/span&gt;
    &lt;span class="c1"&gt;// that don't have an active @Transactional context.&lt;/span&gt;
    &lt;span class="c1"&gt;// Without this: event silently dropped. Nothing tells you.&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This restores synchronous execution when there's no transaction — which gives you back the mid-transaction timing problem you started with. You're going in circles.&lt;/p&gt;

&lt;h2&gt;
  
  
  When AFTER_COMMIT is fine and when it isn't
&lt;/h2&gt;

&lt;p&gt;The real question is: what happens if the listener never fires?&lt;/p&gt;

&lt;p&gt;If the answer is "stale cache for 60 seconds" or "audit log has a gap" — &lt;code&gt;AFTER_COMMIT&lt;/code&gt; is fine. The business isn't broken.&lt;/p&gt;

&lt;p&gt;If the answer is "customer didn't get charged", "duplicate order created", or "inventory not decremented" — you need the outbox pattern. Write the event as a row in an outbox table inside the same transaction. A separate process (a scheduler or Debezium reading the WAL) picks it up and publishes it after commit. Now the event delivery is reliable and tied to the transaction at the DB level, not the application level.&lt;/p&gt;

&lt;p&gt;The outbox is more infrastructure. But it's the correct choice when losing an event corrupts state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off, summarised
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Stale reads&lt;/th&gt;
&lt;th&gt;Silent loss on failure&lt;/th&gt;
&lt;th&gt;Works outside &lt;code&gt;@Transactional&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@EventListener&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@TransactionalEventListener(AFTER_COMMIT)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (silent drop)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@TransactionalEventListener(AFTER_COMMIT, fallbackExecution = true)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mixed&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbox pattern&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;@EventListener&lt;/code&gt; vs &lt;code&gt;@TransactionalEventListener&lt;/code&gt; — almost identical names, completely different behavior. Most teams find this difference via a production incident, not the docs.&lt;/p&gt;

&lt;p&gt;How do you handle post-commit side effects in your services?&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>events</category>
      <category>transactions</category>
    </item>
    <item>
      <title>🧩 Your MapStruct Mappers Are Hiding Null Bugs</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Wed, 17 Jun 2026 21:54:58 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/your-mapstruct-mappers-are-hiding-null-bugs-33m5</link>
      <guid>https://dev.to/code_with_kyryl/your-mapstruct-mappers-are-hiding-null-bugs-33m5</guid>
      <description>&lt;p&gt;Someone adds a field to a DTO. The entity already has it. MapStruct compiles fine, the tests pass, the PR merges. Two weeks later the field is null in production and nobody knows why.&lt;/p&gt;

&lt;p&gt;The compiler knew. It just wasn't told to care.&lt;/p&gt;

&lt;p&gt;MapStruct is the default mapping library on most Java teams I've worked on, and almost nobody touches the config past &lt;code&gt;@Mapper&lt;/code&gt;. That default config is exactly what lets the bug above happen. Here are the three things I add to every mapper to make it safe, testable, and predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Make the compiler fail on missing mappings
&lt;/h2&gt;

&lt;p&gt;By default, when a target field has no matching source, MapStruct's &lt;code&gt;unmappedTargetPolicy&lt;/code&gt; is &lt;code&gt;WARN&lt;/code&gt;. A warning in an annotation processor is noise. It scrolls past in the build log and nobody reads it. So the field stays null and you find out from a bug report.&lt;/p&gt;

&lt;p&gt;Flip it to &lt;code&gt;ERROR&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Mapper&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;componentModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MappingConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ComponentModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SPRING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unmappedTargetPolicy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ReportingPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ERROR&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;UserMapper&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;UserDto&lt;/span&gt; &lt;span class="nf"&gt;map&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;source&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 build fails the moment a target field has no source. Add &lt;code&gt;phoneNumber&lt;/code&gt; to &lt;code&gt;UserDto&lt;/code&gt; without a matching source field, and the compile breaks with the exact field name. You fix it before the code ever runs.&lt;/p&gt;

&lt;p&gt;This is the whole point. Without &lt;code&gt;ERROR&lt;/code&gt;, completeness is a thing humans have to remember. With it, the compiler enforces it for free. The cost of catching a mistake drops from "production incident plus a debugging session" to "a red build you fix in thirty seconds".&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One component model, two ways to get the mapper
&lt;/h2&gt;

&lt;p&gt;Declare the component model with the constant, not a string literal:&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;@Mapper&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;componentModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MappingConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ComponentModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SPRING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;UserMapper&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;UserDto&lt;/span&gt; &lt;span class="nf"&gt;map&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;source&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes the generated mapper a Spring bean, so you inject it like anything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserService&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;UserMapper&lt;/span&gt; &lt;span class="n"&gt;userMapper&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;UserService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserMapper&lt;/span&gt; &lt;span class="n"&gt;userMapper&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;userMapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userMapper&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;Good for production. Annoying for unit tests. You don't want to boot a Spring context just to test that one mapper turns a &lt;code&gt;User&lt;/code&gt; into a &lt;code&gt;UserDto&lt;/code&gt;. So don't. MapStruct generates a plain implementation you can grab directly:&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;UserMapperTest&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;UserMapper&lt;/span&gt; &lt;span class="n"&gt;mapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mappers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMapper&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserMapper&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="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;mapsAllFields&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;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Kyryl"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"kyryl@example.com"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;UserDto&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&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;dto&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="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&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;dto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Kyryl"&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;Mappers.getMapper()&lt;/code&gt; returns a real instance with zero context startup. The test runs in milliseconds. Same mapper, two access paths: Spring injection in the app, the factory in tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Let MapStruct resolve types for you
&lt;/h2&gt;

&lt;p&gt;This is where most hand-written mapping code is wasted. People write loops and null checks and delegation calls that MapStruct will generate if you let it. Three patterns cover almost everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Collections
&lt;/h3&gt;

&lt;p&gt;Declare the single-element mapping and the list overload. You don't implement the loop, MapStruct derives it from the element mapping it already has.&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;@Mapper&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;componentModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MappingConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ComponentModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SPRING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;UserMapper&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;UserDto&lt;/span&gt; &lt;span class="nf"&gt;map&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;source&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserDto&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated &lt;code&gt;map(List&amp;lt;User&amp;gt;)&lt;/code&gt; iterates and calls &lt;code&gt;map(User)&lt;/code&gt; per element. No &lt;code&gt;for&lt;/code&gt; loop in your code, no &lt;code&gt;.stream().map(...).toList()&lt;/code&gt; boilerplate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enums
&lt;/h3&gt;

&lt;p&gt;Map an enum once with &lt;code&gt;@ValueMappings&lt;/code&gt;. The catch nobody handles until it bites them: a new constant added to the source enum later. &lt;code&gt;ANY_REMAINING&lt;/code&gt; is the default branch that keeps that from throwing at runtime.&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;@ValueMappings&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt;
    &lt;span class="nd"&gt;@ValueMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ENABLED"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ACTIVE"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="nd"&gt;@ValueMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"UNKNOWN"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MappingConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ANY_REMAINING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="nc"&gt;StatusDto&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Status&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Someone adds &lt;code&gt;Status.SUSPENDED&lt;/code&gt; next quarter and forgets your mapper exists. Without &lt;code&gt;ANY_REMAINING&lt;/code&gt;, MapStruct throws an &lt;code&gt;IllegalArgumentException&lt;/code&gt; the first time that value flows through. With it, the value maps to &lt;code&gt;UNKNOWN&lt;/code&gt; and you handle it gracefully instead of paging someone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Composition
&lt;/h3&gt;

&lt;p&gt;Compose mappers with &lt;code&gt;uses&lt;/code&gt;. MapStruct picks the right sub-mapper by type, so you never write delegation by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Mapper&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;StatusMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AddressMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderMapper&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OrderDto&lt;/span&gt; &lt;span class="nf"&gt;map&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;source&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;Order&lt;/code&gt; has a &lt;code&gt;Status&lt;/code&gt; field and an &lt;code&gt;Address&lt;/code&gt; field, MapStruct sees the types, finds the matching mapper in &lt;code&gt;uses&lt;/code&gt;, and calls it. Add a third nested type later, register its mapper in &lt;code&gt;uses&lt;/code&gt;, done. No manual wiring.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;unmappedTargetPolicy = ERROR&lt;/code&gt; is not free. Every field you intentionally leave unmapped now needs an explicit &lt;code&gt;@Mapping(target = "auditTimestamp", ignore = true)&lt;/code&gt;. On a DTO with thirty fields where you only care about ten, that's a wall of &lt;code&gt;ignore = true&lt;/code&gt; lines, and you have to maintain them.&lt;/p&gt;

&lt;p&gt;That's real overhead and I won't pretend it isn't. My rule of thumb: turn it on the moment a mapper has more than a handful of fields, or sits on a service where DTOs and entities change often. That's exactly where the silent-null bug lives. For a tiny three-field mapper that never changes, the ceremony isn't worth it.&lt;/p&gt;

&lt;p&gt;The other cost is that &lt;code&gt;ANY_REMAINING&lt;/code&gt; can hide a mapping you genuinely meant to add. It trades a loud runtime crash for a quiet fallback. That's the right call for resilience, but pair it with a test that asserts the constants you care about actually map where you expect.&lt;/p&gt;

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

&lt;p&gt;Add these up and the mapper stops being a place bugs hide. The compiler enforces completeness. The tests run without a container. The boilerplate that used to be hand-written loops and switch statements is generated from a couple of method signatures.&lt;/p&gt;

&lt;p&gt;Define the shape once, let the generator handle the mechanical parts. That's the whole pitch.&lt;/p&gt;




&lt;p&gt;What does your &lt;code&gt;@Mapper&lt;/code&gt; config look like? Do you enforce &lt;code&gt;ERROR&lt;/code&gt;, or has the WARN default ever shipped a null to production on you? Curious whether teams turn this on by default or only after it bites.&lt;/p&gt;

</description>
      <category>java</category>
      <category>mapstruct</category>
      <category>springboot</category>
      <category>mapping</category>
    </item>
    <item>
      <title>Confluence Docs Lie. Tie Your Documentation to Code Instead📘</title>
      <dc:creator>Kyryl</dc:creator>
      <pubDate>Sat, 13 Jun 2026 20:23:11 +0000</pubDate>
      <link>https://dev.to/code_with_kyryl/confluence-docs-lie-tie-your-documentation-to-code-instead-1nnl</link>
      <guid>https://dev.to/code_with_kyryl/confluence-docs-lie-tie-your-documentation-to-code-instead-1nnl</guid>
      <description>&lt;p&gt;Every team has that Confluence page. The one that was carefully written to explain what the service does, what the API looks like, what each DB column means. Someone spent real time on it. It was accurate when it was written.&lt;/p&gt;

&lt;p&gt;Six months later, it's fiction.&lt;/p&gt;

&lt;p&gt;This isn't a discipline problem. I've seen it at teams with strong engineering culture, with good processes, with people who genuinely care about documentation. The Confluence page still goes stale. The root cause is structural, and until you treat it that way, nothing changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why external docs always drift
&lt;/h2&gt;

&lt;p&gt;When documentation lives in a different place than the code, there's no mechanism that forces them to stay in sync. It relies entirely on people remembering to update two separate things every time anything changes. A column gets renamed, an endpoint response adds a field, a Kafka message format evolves — the ticket gets closed, the code gets merged, and the Confluence page stays where it was.&lt;/p&gt;

&lt;p&gt;AI writing more of your code makes this worse. More code ships faster now. The documentation debt compounds faster too.&lt;/p&gt;

&lt;p&gt;The fix isn't better processes or more reminders in the PR template. It's to stop maintaining documentation as a separate artifact and tie it directly to the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three tools that keep docs honest
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Swagger on your REST controllers
&lt;/h3&gt;

&lt;p&gt;If you're using Spring Boot with springdoc-openapi, you already have the infrastructure. You just need to use it properly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="nd"&gt;@RequestMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/orders"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Tag&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Order lifecycle management"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderController&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Operation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Get order by ID"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Returns a single order. 404 if the order doesn't exist or belongs to a different customer."&lt;/span&gt;
    &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@ApiResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;responseCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"200"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Order found"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@ApiResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;responseCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"404"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Order not found"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderDto&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@Parameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Internal order ID"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;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;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;ResponseEntity:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;notFound&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Swagger UI becomes a living contract. It's always current because it's generated from the code. Frontend devs, QA, external teams can check it themselves without asking you anything.&lt;/p&gt;

&lt;p&gt;If your controllers are getting buried in annotations, the previous post on &lt;a href="https://dev.to/kirill_f_27d2a0468dd9216e/your-spring-boot-controllers-are-80-swagger-noise-heres-the-fix-1k9h"&gt;moving Swagger to interfaces&lt;/a&gt; covers that pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostgreSQL column comments in DDL
&lt;/h3&gt;

&lt;p&gt;PostgreSQL supports &lt;code&gt;COMMENT ON&lt;/code&gt; natively. It's been there forever, almost nobody uses it.&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;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt;    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total_cents&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'Customer purchase orders. One row per order, regardless of item count.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;COLUMN&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;status&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt;
    &lt;span class="s1"&gt;'Order lifecycle state. Valid values: PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED. '&lt;/span&gt;
    &lt;span class="s1"&gt;'Transitions are enforced in OrderService, not at the DB level.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;COLUMN&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;total_cents&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt;
    &lt;span class="s1"&gt;'Order total in cents. Always positive. Divide by 100 for display. '&lt;/span&gt;
    &lt;span class="s1"&gt;'Never store as decimal — rounding bugs.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These comments ship with the schema. They live in your migration files. When the column changes, the comment gets updated in the same commit, by the same person, in the same PR review.&lt;/p&gt;

&lt;p&gt;You can query them 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="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pgd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_statio_all_tables&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_description&lt;/span&gt; &lt;span class="n"&gt;pgd&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pgd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objoid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relid&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&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="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&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;AND&lt;/span&gt; &lt;span class="n"&gt;ordinal_position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pgd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objsubid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&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;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And any decent DB client (DBeaver, DataGrip) surfaces them automatically when you hover over a column. No page to open. The schema explains itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  doc attribute in Avro schemas
&lt;/h3&gt;

&lt;p&gt;If you're using Avro for Kafka messages, the &lt;code&gt;doc&lt;/code&gt; field is part of the spec. It's not optional in any meaningful sense if you care about your consumers understanding the contract.&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"record"&lt;/span&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;"OrderCreatedEvent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.example.events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"doc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Published when a new order is placed. Consumed by inventory-service, billing-service, and notification-service."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fields"&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="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;"orderId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"long"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"doc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Internal order ID from the orders table."&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;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;"customerId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"long"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"doc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"References users.id. The customer who placed the order."&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;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;"totalCents"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"long"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"doc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Order total in cents. Always positive. Same semantics as orders.total_cents."&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;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;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"doc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Initial status at publish time. Always PENDING for this event."&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;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;When another team's developer needs to understand what this event carries, they read the schema. If the schema is registered in a Schema Registry (Confluent or otherwise), the docs are browsable there. No Confluence page, no hunting down who owns the topic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who else benefits
&lt;/h2&gt;

&lt;p&gt;The obvious beneficiaries are engineers. Less time spent answering "what does this field mean" questions, better context when onboarding, less cognitive overhead when reading unfamiliar code.&lt;/p&gt;

&lt;p&gt;But the compounding effect shows up with less technical people.&lt;/p&gt;

&lt;p&gt;Give your BA a dump of the database DDL — just the schema files, no actual data. Or give them read-only access to a dev environment DB. With an AI assistant they can load those schemas and start asking questions: what do we store, what are the valid states for this field, how are orders and customers related. They get answers without pinging a developer. The schema is always current because it's in version control.&lt;/p&gt;

&lt;p&gt;Same with Swagger. A product manager who can open Swagger UI and see the actual endpoints, parameters, and response shapes during a design discussion is a product manager who isn't blocked waiting for you to write up an email.&lt;/p&gt;

&lt;p&gt;Your AI coding assistant also benefits. An LLM reading your schema with &lt;code&gt;doc&lt;/code&gt; fields and &lt;code&gt;COMMENT ON COLUMN&lt;/code&gt; entries has significantly more context than one reading bare column names. The quality of generated code improves with it.&lt;/p&gt;

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

&lt;p&gt;Writing proper Swagger annotations, DDL comments, and Avro doc fields takes time upfront. Real time. When you're under pressure to ship the feature by Friday, adding good &lt;code&gt;COMMENT ON COLUMN&lt;/code&gt; entries to the migration doesn't feel like a priority.&lt;/p&gt;

&lt;p&gt;It's also harder to enforce than a Confluence page. With Confluence, you can at least point to a URL and say "write it here." With code-tied docs, you need to make it part of the review culture — PRs that add columns without comments don't get merged.&lt;/p&gt;

&lt;p&gt;That's a real overhead, and I won't pretend otherwise.&lt;/p&gt;

&lt;p&gt;But the alternative is paying that cost continuously. Every time someone joins the team and has to ask what a column means. Every time a BA opens a Jira ticket that could have been answered by looking at the schema. Every time your Confluence page sends someone down the wrong path because nobody updated it after the last refactor.&lt;/p&gt;

&lt;p&gt;The upfront investment is a one-time cost per artifact. Stale docs are a recurring cost forever.&lt;/p&gt;

&lt;p&gt;Write them once. Keep them with the code. They won't lie.&lt;/p&gt;




&lt;p&gt;Do you have a practice for keeping docs in sync with code, or is it still Confluence pages and hope? Curious what's actually working at different team sizes.&lt;/p&gt;

</description>
      <category>java</category>
      <category>documentation</category>
      <category>postgres</category>
      <category>kafka</category>
    </item>
  </channel>
</rss>
