<?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: Paolo</title>
    <description>The latest articles on DEV Community by Paolo (@purbano).</description>
    <link>https://dev.to/purbano</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%2F4046772%2Fa91a91a9-dd65-4bc9-8621-a789266f92b9.jpg</url>
      <title>DEV Community: Paolo</title>
      <link>https://dev.to/purbano</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/purbano"/>
    <language>en</language>
    <item>
      <title>That Username Is Taken. How Did the Form Know Before I Finished Typing?</title>
      <dc:creator>Paolo</dc:creator>
      <pubDate>Thu, 01 Oct 2026 09:27:15 +0000</pubDate>
      <link>https://dev.to/purbano/that-username-is-taken-how-did-the-form-know-before-i-finished-typing-4bk6</link>
      <guid>https://dev.to/purbano/that-username-is-taken-how-did-the-form-know-before-i-finished-typing-4bk6</guid>
      <description>&lt;p&gt;Start typing a username on a sign-up form and, before you've even reached the last letter, a little message shows up underneath telling you it's taken. Change a letter or add a number and it flips to available, as if the site had been waiting for you.&lt;/p&gt;

&lt;p&gt;I got curious about how that works on a site with millions of accounts, maybe billions. I'd seen it listed as a common system design interview question, so before reading anything I tried to answer it on my own, and my first idea went in the wrong direction. Figuring out what the form is really asking took me from the database index that does most of the work to a data structure I'm only now getting my head around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I started: ruling names out letter by letter
&lt;/h2&gt;

&lt;p&gt;My first idea was to narrow the list as I typed. "m" rules out almost nothing, "ma" a bit more, "map" more still, and by the last letter only the exact name is left to check. It looked efficient, because each keystroke would build on the work of the one before.&lt;/p&gt;

&lt;p&gt;That is the answer to a different question. Narrowing by prefix is how autocomplete works: show me everything that starts with these letters. The sign-up form asks something much smaller. Does this exact string exist, yes or no?&lt;/p&gt;

&lt;p&gt;The intermediate answers don't help with that. Almost every short prefix matches a huge number of taken names, so knowing that "ma" has matches tells me nothing about "maple". The only result the user cares about is the one for the complete string, and as it turns out, the form shouldn't even ask about the partial ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding one name among billions
&lt;/h2&gt;

&lt;p&gt;The lookup itself turned out to be the easy part. Put a unique index on the username column and the database keeps the names sorted in a B-tree. Each node of the tree is a page on disk, and depending on the page size and the length of the keys, it holds a few hundred of them, each pointing to another page one level down. To find a name, the database reads the root, picks the branch where the name would sort, and repeats until it lands on the name or on the spot where it would be.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ic9ghlq9do5tupf3o6i.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%2F1ic9ghlq9do5tupf3o6i.png" alt="A B-tree with about 300 keys per page: 1 page at level 1, 300 at level 2, 90,000 at level 3 and 27 million at level 4, about 8 billion names in total. Looking up " width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because every page fans out to hundreds more, the tree is very wide and very short. With 300 keys per node, four levels already cover 300⁴, about 8 billion entries. With 200 per node you need a fifth. For a table of two billion users, that means four or five hops from the root to the answer.&lt;/p&gt;

&lt;p&gt;In practice it's cheaper still. Every lookup reads the top levels, so they stay in memory, and a single check costs at most a couple of page reads that aren't already cached. That's fast enough to run every time the user pauses.&lt;/p&gt;

&lt;p&gt;One detail matters later: the index should be on a normalized form of the name, something like &lt;code&gt;lower(username)&lt;/code&gt;, so that "Maple" and "maple" collide instead of becoming two accounts.&lt;/p&gt;

&lt;p&gt;The same index also settles what happens when two people submit the same free name at the same moment. Both INSERT statements reach the database, the index lets exactly one of them through, and the other fails with a duplicate-key error that the application can turn into "sorry, that name was just taken". It also means the green "available" under the field is only a hint, because the answer that counts is the one the INSERT gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instant part happens in the browser
&lt;/h2&gt;

&lt;p&gt;The "before you finish typing" part is mostly frontend work. If the form sent a request on every keystroke, typing "maple" would fire five requests, four of them about names nobody wants. So the form waits for a short pause, cancels whatever request is still in flight, and ignores any answer that arrives late.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#username-status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;controller&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;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/api/usernames/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/available`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;available&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;available&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Available&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Taken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&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;Two details are easy to miss. Aborting a fetch only tells the browser to stop waiting, and the server may still run the query. And the &lt;code&gt;latest&lt;/code&gt; counter is there because an old response can slip through if it had already arrived when the abort happened. Without it, the label could briefly show the verdict on "map" while the user is looking at "maple".&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bloom filter, which I'm learning right now
&lt;/h2&gt;

&lt;p&gt;This is the part I'm still studying, and the one that struck me most.&lt;/p&gt;

&lt;p&gt;A Bloom filter is an array of bits, all zero at the start, plus a few hash functions. To add a name, you hash it with each function and switch on the bits at those positions. To check a name, you hash it the same way and look: if any of those bits is off, the name was never added.&lt;/p&gt;

&lt;p&gt;A toy version makes it concrete. Take 10 bits and two hash functions, and suppose (the values are made up) that they send "sunny" to bits 2 and 7, and "rocket" to bits 4 and 9. After adding both names, bits 2, 4, 7 and 9 are on.&lt;/p&gt;

&lt;p&gt;Now check "pixel", which hashes to 3 and 7. Bit 3 is off, so "pixel" is definitely free. Then check "comet", which hashes to 2 and 9. Both bits are on, so the filter says it might be taken, even though nobody registered it. Bit 2 came from "sunny" and bit 9 from "rocket". That's a false positive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fodtceq9z50cpxeyra2tj.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%2Fodtceq9z50cpxeyra2tj.png" alt="A 10-bit Bloom filter step by step: adding " width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A zero means the name was never added, guaranteed. A one only means somebody set that bit, and it may have been somebody else. So a Bloom filter can be wrong when it says "maybe", but it's never wrong when it says "no": there are no false negatives.&lt;/p&gt;

&lt;p&gt;Its size depends on the error rate you accept. The optimal number of bits per name is -ln(p) / (ln 2)², which for a 1% false-positive rate comes to about 9.6 bits, with 7 hash functions. For two billion names that's around 19 billion bits, roughly 2.4 GB. A 10-character name stored as plain ASCII takes 80 bits on its own, so the filter is about an eighth of the bare list of names, and its size doesn't grow with their length.&lt;/p&gt;

&lt;p&gt;I wouldn't write one myself, since there are solid implementations for most languages. What I wanted to understand was what it actually speeds up. A Bloom filter is fast at saying no. When it says maybe, the form still has to ask the database, because the maybe could be a false positive. So the filter only saves work on names that are free.&lt;/p&gt;

&lt;p&gt;And think about who's typing. Someone picking a username tries the obvious names first, like their own name or a word they like, and those are exactly the names most likely to be taken already. For them the filter says maybe, the database gets queried anyway, and I've added a 2.4 GB structure that has to stay in sync with the table. It can't even forget a name when an account is deleted, because switching a bit off could erase a bit that another name relies on.&lt;/p&gt;

&lt;p&gt;Where it does fit is the opposite case: suggesting alternatives when a name is taken. Generated candidates are mostly free, so the usual answer is no, and that's exactly where a Bloom filter could earn its memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing I haven't settled
&lt;/h2&gt;

&lt;p&gt;"admin" written with a Cyrillic а (U+0430) is a different string from "admin" with a Latin a (U+0061). The index sees two different names while a person sees the same one, so whoever registers the lookalike can pass for the original.&lt;/p&gt;

&lt;p&gt;Unicode Technical Standard #39 describes a "skeleton" for exactly this: it maps each character to a prototype of the characters it can be mistaken for, so two names that look alike end up with the same skeleton, and a unique index on the skeleton would catch them. The blunt alternative is to accept only lowercase a to z, digits and a couple of symbols, and most of the problem never shows up.&lt;/p&gt;

&lt;p&gt;I don't know yet which one I'd pick. The skeleton leaves room for people whose names aren't written in Latin letters, while the short whitelist is much harder to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's still worth knowing
&lt;/h2&gt;

&lt;p&gt;Most of us will never write a B-tree or a Bloom filter for production. The database already has the first, and a library has the second. What's worth having is a sense of what each one costs: a B-tree gives an exact answer in a handful of page reads, while a Bloom filter gives a cheap no and a maybe that still needs checking, for about 10 bits a name. That's enough to choose between them for the right reasons, and to notice when the clever option doesn't help.&lt;/p&gt;

&lt;p&gt;Have you ever put a Bloom filter in front of a database and seen it actually pay off? I'd like to hear where it did.&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>webdev</category>
      <category>database</category>
      <category>bloomfilter</category>
    </item>
    <item>
      <title>Your Search Query Is a Program: Composing Role-Based SQL With the Strategy Pattern</title>
      <dc:creator>Paolo</dc:creator>
      <pubDate>Sat, 26 Sep 2026 18:19:15 +0000</pubDate>
      <link>https://dev.to/purbano/your-search-query-is-a-program-composing-role-based-sql-with-the-strategy-pattern-3o3a</link>
      <guid>https://dev.to/purbano/your-search-query-is-a-program-composing-role-based-sql-with-the-strategy-pattern-3o3a</guid>
      <description>&lt;p&gt;Every business application has this screen somewhere: a table of records, filter boxes above it, and a user who may only see part of the data. The first query is a &lt;code&gt;SELECT&lt;/code&gt; with a &lt;code&gt;WHERE&lt;/code&gt; on the user's office. Filters arrive one at a time, then a role that sees two offices, then an auditor who sees everything once approved. Eventually the query is a two-hundred-line method nobody wants to touch.&lt;/p&gt;

&lt;p&gt;Our argument is that this query was never a string. A search with optional filters and role-based visibility is application logic, and one of its invariants is a security boundary. Built by appending text, it gets the testability and review process of a text file. Built from small units that contribute fragments to a builder that enforces the rules, it gets treated like the rest of the code. The pattern is Strategy, used twice: one family of strategies decides what a user may see, the other what the user asked for, and neither writes the whole query.&lt;/p&gt;

&lt;p&gt;The companion repository is &lt;a href="https://github.com/PaoloUrbano/search-query-composition-demo" rel="noopener noreferrer"&gt;&lt;strong&gt;search-query-composition-demo&lt;/strong&gt;&lt;/a&gt;: both versions of the search on the same schema, tested against a real SQL Server through Testcontainers, so you need Docker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versions used:&lt;/strong&gt; Spring Boot 4.1.1, Spring Framework 7.0.9, Flyway 12.4.0, Testcontainers 2.0.5, Microsoft JDBC Driver for SQL Server 13.4.0, SQL Server 2025 (&lt;code&gt;mcr.microsoft.com/mssql/server:2025-CU9-ubuntu-24.04&lt;/code&gt;), Java 21. No JPA: Spring JDBC, &lt;code&gt;NamedParameterJdbcTemplate&lt;/code&gt; and records.&lt;/p&gt;

&lt;h2&gt;
  
  
  The domain
&lt;/h2&gt;

&lt;p&gt;A document platform for an organization with a national office, regional offices, and local offices under each region, modelled in &lt;code&gt;org_unit&lt;/code&gt; with a &lt;code&gt;parent_id&lt;/code&gt;. The exception is the &lt;em&gt;chartered units&lt;/em&gt;, local offices that report straight to the national office: a regional supervisor sees one only while holding an explicit, dated delegation for it.&lt;/p&gt;

&lt;p&gt;Documents belong to a unit and have a type, a status (&lt;code&gt;DRAFT&lt;/code&gt;, &lt;code&gt;SUBMITTED&lt;/code&gt;, &lt;code&gt;APPROVED&lt;/code&gt;, &lt;code&gt;ARCHIVED&lt;/code&gt;), an author, dates, attachments and tags. Five roles decide visibility:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Visibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOCAL_OFFICER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;their own unit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REGIONAL_SUPERVISOR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;their region and its local offices, plus chartered units with an active delegation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NATIONAL_ADMIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;everything, including the author's email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AUDITOR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every unit, only &lt;code&gt;APPROVED&lt;/code&gt; or &lt;code&gt;ARCHIVED&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELEGATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;only the units they hold an active delegation for&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On top of that come ten optional filters (region, unit, type, status, date range, attachments, author, title, tag, overdue), several of which need joins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version that grows naturally
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;legacy&lt;/code&gt; package has the search you get by adding one requirement at a time: one method, a &lt;code&gt;StringBuilder&lt;/code&gt; that starts with &lt;code&gt;SELECT DISTINCT&lt;/code&gt; and every join any filter might need, then one &lt;code&gt;if&lt;/code&gt; per role and one per filter, with catch-all predicates for the simple filters. For a local officer it sends this (abridged):&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;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;author_email&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;org_unit&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_unit_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author_id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;attachment&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;document_tag&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tag_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_unit_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;userUnitId&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;regionId&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;regionId&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works: apart from two quirks covered below, the repository's tests show it returns the same rows as the composed version. The problems are structural.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rules are not addressable.&lt;/strong&gt; "A supervisor sees chartered units only with an active delegation" is a real business rule, and here it is five &lt;code&gt;sql.append&lt;/code&gt; calls inside an &lt;code&gt;else if&lt;/code&gt;. There is nothing to call, reuse or test in isolation. When a count query or an export needs the same visibility, the only option is to copy those lines, and the copies drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch-alls fight the plan cache.&lt;/strong&gt; SQL Server caches one plan per statement text, and this text never changes, so every combination of filters shares one plan: compiled for the values of the first call, and required to be valid whenever any parameter is &lt;code&gt;NULL&lt;/code&gt;, which pushes the optimizer towards scans. &lt;code&gt;OPTION (RECOMPILE)&lt;/code&gt; trades that for a compilation on every execution; Erland Sommarskog's &lt;a href="https://www.sommarskog.se/dyn-search.html" rel="noopener noreferrer"&gt;Dynamic Search Conditions in T-SQL&lt;/a&gt; covers the options in depth. SQL Server 2025 adds &lt;a href="https://learn.microsoft.com/en-us/sql/relational-databases/performance/optional-parameter-optimization?view=sql-server-ver17" rel="noopener noreferrer"&gt;Optional Parameter Plan Optimization&lt;/a&gt;, which caches plan variants by &lt;code&gt;NULL&lt;/code&gt; state, and how it copes with ten optional predicates is something to measure, not assume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data is fetched, then hidden.&lt;/strong&gt; The author's email is selected for every role and set to &lt;code&gt;null&lt;/code&gt; in the row mapper unless the user is an admin. It leaves the database for users who may not see it, with one &lt;code&gt;if&lt;/code&gt; in a lambda between it and the response.&lt;/p&gt;

&lt;p&gt;Two smaller things: the &lt;code&gt;LEFT JOIN&lt;/code&gt;s multiply rows and &lt;code&gt;DISTINCT&lt;/code&gt; hides that instead of avoiding it, and the mapper reads the nullable reviewer id with &lt;code&gt;rs.getLong&lt;/code&gt;, which returns &lt;code&gt;0&lt;/code&gt; for &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one query per role does not fix it
&lt;/h2&gt;

&lt;p&gt;Splitting the method by role gives each role clean SQL, but the search varies along two independent axes: visibility depends on who asks, filtering on what they asked. A query per role removes the first axis by multiplying the second. Five roles means five copies of the tag filter, and every fix to a filter is five fixes.&lt;/p&gt;

&lt;p&gt;It also hides the security boundary. In each query the visibility predicate sits inline among the filters, and a reviewer reading a diff cannot tell whether a changed line affects what the user asked for or what the user may see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fragments that compose
&lt;/h2&gt;

&lt;p&gt;The composed version keeps the axes apart. A &lt;strong&gt;visibility scope&lt;/strong&gt; decides what a user may see: one per role, exactly one per search, never optional. A &lt;strong&gt;filter contributor&lt;/strong&gt; decides what the user asked for: one per filter, each deciding whether it applies. Both add fragments (predicates, joins, CTEs, columns, parameters) to a builder, and neither sees the whole query.&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;VisibilityScope&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Role&lt;/span&gt; &lt;span class="nf"&gt;role&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;restrict&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchQuery&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SearchContext&lt;/span&gt; &lt;span class="n"&gt;context&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="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;FilterContributor&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;appliesTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchContext&lt;/span&gt; &lt;span class="n"&gt;context&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;contribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchQuery&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SearchContext&lt;/span&gt; &lt;span class="n"&gt;context&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;SearchContext&lt;/code&gt; is a record of the user, the criteria and today's date, resolved once per search for a reason that comes up in the security section. The supervisor scope is where the chartered exception lives, and the only place it lives:&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;RegionalSupervisorScope&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;VisibilityScope&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;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Cte&lt;/span&gt; &lt;span class="no"&gt;SUPERVISED_UNIT&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;Cte&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"supervised_unit"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"""
            SELECT u.id AS unit_id
            FROM org_unit u
            WHERE u.id = :userUnitId OR u.parent_id = :userUnitId
            UNION
            SELECT dl.org_unit_id
            FROM delegation dl
            JOIN org_unit cu ON cu.id = dl.org_unit_id
            WHERE dl.delegate_user_id = :userId
              AND cu.chartered = 1
              AND dl.valid_from &amp;lt;= :today
              AND (dl.valid_to IS NULL OR dl.valid_to &amp;gt;= :today)
            """&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;Role&lt;/span&gt; &lt;span class="nf"&gt;role&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REGIONAL_SUPERVISOR&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;restrict&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchQuery&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SearchContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SUPERVISED_UNIT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;restrictVisibility&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"d.org_unit_id IN (SELECT unit_id FROM supervised_unit)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"userUnitId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;unitId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"userId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"today"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;today&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the rule changes, this is the one class to open. The national admin scope calls &lt;code&gt;query.visibleToAll()&lt;/code&gt;, which adds no predicate but records that a scope decided; the builder refuses a query where none did. That scope also adds the email column, so for every other role it is never selected.&lt;/p&gt;

&lt;p&gt;A filter looks the same from outside:&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;RegionFilter&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;FilterContributor&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;appliesTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchContext&lt;/span&gt; &lt;span class="n"&gt;context&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;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;regionId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;contribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchQuery&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SearchContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Joins&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UNIT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unit.id = :regionId OR unit.parent_id = :regionId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"regionId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;regionId&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;Keep an eye on the &lt;code&gt;OR&lt;/code&gt; in that predicate; it comes back in the security section.&lt;/p&gt;

&lt;p&gt;Every contributor declares the joins it depends on, even ones the base query already has. Shared joins are constants with a key, like &lt;code&gt;new Join("unit", "JOIN org_unit unit ON unit.id = d.org_unit_id")&lt;/code&gt;; the builder adds each key once and throws if a key arrives with different SQL.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;VisibilityScopeRegistry&lt;/code&gt; collects the scopes into a map by role, so two scopes for one role fail at startup. A role with no scope throws &lt;code&gt;NoVisibilityScopeException&lt;/code&gt; at search time, and nothing reads a missing scope as "no restriction".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DocumentQueryComposer&lt;/code&gt; puts it together; the registry, the &lt;code&gt;List&amp;lt;FilterContributor&amp;gt;&lt;/code&gt; injected by Spring and a &lt;code&gt;Clock&lt;/code&gt; come in through its constructor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;SqlQuery&lt;/span&gt; &lt;span class="nf"&gt;compose&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SearchUser&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SearchCriteria&lt;/span&gt; &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;VisibilityScope&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;scopeFor&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;role&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="nc"&gt;SearchContext&lt;/span&gt; &lt;span class="n"&gt;context&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;SearchContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocalDate&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="n"&gt;clock&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="nc"&gt;SearchQuery&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SearchQuery&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="s"&gt;"document d"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Joins&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UNIT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Joins&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DOCUMENT_TYPE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Joins&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AUTHOR&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"d.id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"d.reference"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"d.title"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"d.status"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"unit.code AS unit_code"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"doc_type.code AS document_type"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"author.username AS author"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"d.reviewer_id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"d.created_at"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"d.due_date"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;restrict&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&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;FilterContributor&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;filters&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;filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;appliesTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sortField&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sortDirection&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SortField&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="nc"&gt;SortDirection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ASC&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a filter means adding a class, and order does not matter because every fragment ends up as one more &lt;code&gt;AND&lt;/code&gt;ed condition in its own parentheses. Each combination of filters also produces a different statement text, so SQL Server caches a plan per combination actually used, with only the predicates that are really there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The builder
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://github.com/PaoloUrbano/search-query-composition-demo/blob/main/src/main/java/com/example/searchdemo/query/SearchQuery.java" rel="noopener noreferrer"&gt;full &lt;code&gt;SearchQuery&lt;/code&gt;&lt;/a&gt; also handles CTEs, joins and ordering. These two methods carry the rules the next section depends on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;SearchQuery&lt;/span&gt; &lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;requireIdentifier&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;QueryCompositionException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Parameter :"&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;" has no value"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putIfAbsent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&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;existing&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&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;QueryCompositionException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"Parameter :"&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;" is already bound to "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;", refusing "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;value&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;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;SqlQuery&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;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;visibilityDecided&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;QueryCompositionException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No visibility scope was applied to this query"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;StringBuilder&lt;/span&gt; &lt;span class="n"&gt;sql&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;StringBuilder&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// WITH, SELECT, FROM and JOIN clauses omitted&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;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;conditions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;concat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;visibilityPredicates&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;predicates&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="c1"&gt;// An OR inside one fragment must never escape into the AND chain around it&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;predicate&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"("&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;()&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WHERE "&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n  AND "&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'\n'&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ORDER BY omitted&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;SqlQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parameters&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;h2&gt;
  
  
  Security is a property of the composition
&lt;/h2&gt;

&lt;p&gt;Nobody can review every combination of fragments written by different people, so the builder has to make the unsafe combinations impossible to build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Values are parameters
&lt;/h3&gt;

&lt;p&gt;Every value goes through &lt;code&gt;param(...)&lt;/code&gt; and reaches SQL Server as a bound parameter. The builder cannot prove that nobody concatenated a value, and Java has no safe SQL templating (String Templates were previewed in JDK 21 and 22, then withdrawn). What it can do is reject any fragment containing &lt;code&gt;'&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;--&lt;/code&gt; or &lt;code&gt;/*&lt;/code&gt;. Without quotes there is nowhere to put a string literal, so even the auditor's fixed statuses travel as parameters. It is a tripwire, not a proof, and it keeps every piece of data a fragment touches visible in the &lt;code&gt;param&lt;/code&gt; calls under it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Identifiers are a whitelist
&lt;/h3&gt;

&lt;p&gt;Column names cannot be bound, and SQL Server rejects a variable in &lt;code&gt;ORDER BY&lt;/code&gt; outright. &lt;code&gt;SortField&lt;/code&gt; maps the names the UI may send to real columns:&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;SortField&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;CREATED_AT&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"createdAt"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"d.created_at"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;TITLE&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"d.title"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// other constants, fields, constructor and fromLogicalName omitted&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The builder's only sort method is &lt;code&gt;orderBy(SortField field, SortDirection direction)&lt;/code&gt;, with no &lt;code&gt;String&lt;/code&gt; overload to misuse.&lt;/p&gt;

&lt;h3&gt;
  
  
  The AND/OR precedence leak
&lt;/h3&gt;

&lt;p&gt;This is the bug the design is built around, and it gets past both code review and most test suites.&lt;/p&gt;

&lt;p&gt;The region filter's predicate, &lt;code&gt;unit.id = :regionId OR unit.parent_id = :regionId&lt;/code&gt;, is correct. So is the local officer's &lt;code&gt;d.org_unit_id = :userUnitId&lt;/code&gt;. Concatenated the way a string builder does 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;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_unit_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;userUnitId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;regionId&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;regionId&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AND&lt;/code&gt; binds tighter than &lt;code&gt;OR&lt;/code&gt;, as &lt;code&gt;*&lt;/code&gt; binds tighter than &lt;code&gt;+&lt;/code&gt;, so the database reads:&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;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_unit_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;userUnitId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;regionId&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second branch has no visibility predicate at all: any document in any local office under the requested region matches, whoever asks. In the demo data, a local officer from the north who filters on the south region receives every document of the south's local office. &lt;code&gt;PrecedenceLeakTest&lt;/code&gt; runs those two concatenated strings against SQL Server and asserts the leaked rows, then runs the same fragments through the builder and gets nothing back.&lt;/p&gt;

&lt;p&gt;It survives review because each fragment is correct on its own; the bug is in the space between them, which nobody wrote. It survives testing because the natural test checks that the right documents come back. Filtering on their own region, the northern officer does get all of their documents, plus those of the other northern office (the test has that case too). Only a test that asserts absence catches it, and the authorization matrix below does exactly that.&lt;/p&gt;

&lt;p&gt;The legacy method gets this right almost by accident, since a catch-all only works inside parentheses. The leak arrives the day someone tidies it into &lt;code&gt;if (regionId != null) sql.append("AND unit.id = :regionId OR unit.parent_id = :regionId ")&lt;/code&gt;, which reads like an improvement.&lt;/p&gt;

&lt;p&gt;The builder's defence is the commented line in &lt;code&gt;build()&lt;/code&gt;: every predicate is wrapped in parentheses, visibility first. Contributors cannot forget it or opt out, and the worst a stray &lt;code&gt;OR&lt;/code&gt; can do is return the wrong subset of what the user could see anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Parameter names collide
&lt;/h3&gt;

&lt;p&gt;Contributors are written independently, so two of them will eventually pick the same parameter name. Sometimes that is right: the supervisor scope and the overdue filter both bind &lt;code&gt;:today&lt;/code&gt;. Sometimes it is a hole. Had the local officer scope bound its unit as &lt;code&gt;:unitId&lt;/code&gt;, the natural name, the unit filter's &lt;code&gt;:unitId&lt;/code&gt; would silently replace it in a plain map, and the visibility predicate would check the unit the user &lt;em&gt;asked for&lt;/em&gt; instead of the one they &lt;em&gt;belong to&lt;/em&gt;. &lt;code&gt;param&lt;/code&gt; accepts a second binding only if it equals the first, and &lt;code&gt;SearchQueryTest&lt;/code&gt; has that exact case. Naming the scope's parameter &lt;code&gt;userUnitId&lt;/code&gt; makes collisions rarer; the rule catches the rest.&lt;/p&gt;

&lt;p&gt;The same rule is why &lt;code&gt;today&lt;/code&gt; lives in &lt;code&gt;SearchContext&lt;/code&gt;. If the scope and the overdue filter each called &lt;code&gt;LocalDate.now(clock)&lt;/code&gt;, the two calls could straddle midnight, the builder would reject the second binding, and the search would fail. Resolving the date once per search removes the race.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fail closed
&lt;/h3&gt;

&lt;p&gt;The legacy method has branches for local officer, supervisor, auditor and delegate. The admin has none, because "everything" is what you get when no branch adds a predicate, and that is also what any new role gets. The repository has a sixth role, &lt;code&gt;EXTERNAL_REVIEWER&lt;/code&gt;, with no scope yet: &lt;code&gt;FailClosedTest&lt;/code&gt; shows the legacy search returning all 21 documents to it and the composed one throwing &lt;code&gt;NoVisibilityScopeException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The composed version fails closed twice. The registry throws for a role without a scope, and &lt;code&gt;build()&lt;/code&gt; throws if no scope called &lt;code&gt;restrictVisibility&lt;/code&gt; or &lt;code&gt;visibleToAll&lt;/code&gt;, so a buggy &lt;code&gt;restrict&lt;/code&gt; does not produce an unrestricted query either. Seeing everything has to be written down.&lt;/p&gt;

&lt;h3&gt;
  
  
  LIKE has its own syntax
&lt;/h3&gt;

&lt;p&gt;A bound parameter protects the SQL, not the pattern. In the legacy version, &lt;code&gt;100%&lt;/code&gt; also matches "Budget review for 100 days", and since &lt;code&gt;[&lt;/code&gt; opens a character class on SQL Server, &lt;code&gt;[DRAFT]&lt;/code&gt; matches every title containing one of those letters: all 21 documents in the demo. &lt;code&gt;LikePattern&lt;/code&gt; brackets the three special characters, the SQL Server idiom:&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;static&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;StringBuilder&lt;/span&gt; &lt;span class="n"&gt;escaped&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;StringBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&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="kt"&gt;char&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toCharArray&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// SQL Server reads [ as the start of a character class, so it is a wildcard too&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;c&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'%'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'_'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'['&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;escaped&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'['&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;']'&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;escaped&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&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;escaped&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;The whole visibility policy fits in a table, so the main test is one: each row a document, each column a user, each cell whether that user sees it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;MATRIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""
        reference  officer.n1  super.north  super.south  auditor  delegate.one
        N1-001     Y           Y            .            .        .
        S1-002     .           .            Y            Y        Y
        CA-001     .           Y            .            Y        .
        CB-001     .           .            .            .        Y
        """&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full matrix has 21 documents and 7 users, run against both implementations for 294 cases. The &lt;code&gt;.&lt;/code&gt; cells matter as much as the &lt;code&gt;Y&lt;/code&gt; ones, because they assert that something is &lt;em&gt;not&lt;/em&gt; returned.&lt;/p&gt;

&lt;p&gt;Characterization tests run the legacy and the composed search on 20 combinations of criteria for every user and require the same rows in the same order. They are how the two legacy quirks above, the &lt;code&gt;0&lt;/code&gt; reviewer and the unescaped &lt;code&gt;LIKE&lt;/code&gt;, were found, and each became a named test rather than a looser comparison. Testing SQL you inherited without tests is the subject of a follow-up article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs and alternatives
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Spring Data Specifications and the Criteria API.&lt;/strong&gt; A &lt;code&gt;Specification&lt;/code&gt; is the same idea as a filter contributor, and since Criteria predicates are objects combined into a tree, the precedence leak cannot happen there, a real advantage over any string-based builder, this one included. We did not use them because they need JPA entities, and because the visibility rules want CTEs (and screens like this soon want window functions), which standard JPA Criteria lacks and Hibernate 6 offers only through its own extensions. The SQL is also generated rather than written, which matters when this query's plan is what you are tuning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;jOOQ.&lt;/strong&gt; Starting from zero, this is what we would evaluate first. Conditions combine into an AST, so parentheses are rendered for you; generated code makes the schema itself the sort whitelist; CTEs, window functions and the SQL Server dialect are all there. The costs are a code generation step and, for SQL Server, a commercial license, since the open source edition covers only open source databases. Most of this design carries over to it unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row-Level Security.&lt;/strong&gt; SQL Server can attach a filter predicate to &lt;code&gt;document&lt;/code&gt; through a security policy, so every query gets it, ad-hoc reports included, with the user's identity set through &lt;code&gt;sp_set_session_context&lt;/code&gt; on every connection checkout. The predicate is invisible in the application's SQL and harder to test, and Microsoft documents side-channel attacks against it, so we see it as a second line of defence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A closure table.&lt;/strong&gt; &lt;code&gt;u.id = :userUnitId OR u.parent_id = :userUnitId&lt;/code&gt; works only because the hierarchy has exactly three levels. With a fourth, an &lt;code&gt;org_unit_closure(ancestor_id, descendant_id, depth)&lt;/code&gt; table, or a recursive CTE, turns "everything under this unit" into one indexed lookup, and the chartered exception stays in its one scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When this is too much.&lt;/strong&gt; One role, three filters, an internal tool used by five people: write the query, parenthesize it, test it and move on. The same goes for reports that look like searches but are fixed queries. The design earns its cost when visibility has more than two cases, filters keep arriving, and a leak would be an incident rather than a bug report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The search keeps growing because it is where the rules about who may see what meet the user's question about what they want to see. Written as one string, both kinds of rule end up in the same &lt;code&gt;StringBuilder&lt;/code&gt;, and the security boundary is only as good as the last person who appended to it.&lt;/p&gt;

&lt;p&gt;Treated as a program, the query has parts with names: the supervisor rule is a class you can open, and the region filter cannot leak because the builder owns its parentheses. Every fragment is still plain SQL you can paste into a console. It just stops being the place where the rules live.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>sql</category>
      <category>security</category>
    </item>
    <item>
      <title>The Row Says 'system': Spring Data JPA Auditing Outside the HTTP Request</title>
      <dc:creator>Paolo</dc:creator>
      <pubDate>Wed, 23 Sep 2026 18:49:03 +0000</pubDate>
      <link>https://dev.to/purbano/the-row-says-system-spring-data-jpa-auditing-outside-the-http-request-f77</link>
      <guid>https://dev.to/purbano/the-row-says-system-spring-data-jpa-auditing-outside-the-http-request-f77</guid>
      <description>&lt;p&gt;Every table in our platform has the same four columns at the end: &lt;code&gt;created_by&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;updated_by&lt;/code&gt;, &lt;code&gt;updated_at&lt;/code&gt;. Spring Data JPA fills them in for you with four annotations and one &lt;code&gt;@EnableJpaAuditing&lt;/code&gt;. That part takes five minutes.&lt;/p&gt;

&lt;p&gt;Then you look at the data a month later and find rows that say &lt;code&gt;system&lt;/code&gt;, and you cannot tell which ones deserved it. Some of them really were written by a scheduled job. Some were written by a queue consumer on behalf of a real person whose identity got lost on the way. A few were written by a user whose session had not been set up yet. All three look identical in the audit trail, which makes the audit trail useless for exactly the question it exists to answer.&lt;/p&gt;

&lt;p&gt;What follows is what happens after those first five minutes: where the auditor's identity comes from, what to return when there is no logged-in user, what to actually put in that column, and how to package all of it in a library that a dozen services can share without any of them breaking at startup. The setup is deliberately not Spring Security. Our services keep the logged-in user in a session-scoped bean of their own, and the auditor reads from there. If you use &lt;code&gt;SecurityContextHolder&lt;/code&gt;, the interesting parts still apply, because the problem is not where the identity is stored. The problem is that on a scheduler thread there is no identity to read.&lt;/p&gt;

&lt;p&gt;Everything is in a companion repository: &lt;a href="https://github.com/PaoloUrbano/auditor-aware-demo" rel="noopener noreferrer"&gt;&lt;strong&gt;auditor-aware-demo&lt;/strong&gt;&lt;/a&gt;. It is a single Spring Boot module on an in-memory H2 database, with two test classes that cover the two situations described below, so &lt;code&gt;mvn test&lt;/code&gt; is all you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versions used:&lt;/strong&gt; Spring Boot 3.5.16, Spring Data JPA 3.5.13, Hibernate 6.6.53.Final, H2 2.3.232, Java 21. Boot 3.5.x is out of OSS support since July 2026; everything here runs unchanged on 4.x.&lt;/p&gt;

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

&lt;p&gt;Here is the shape of the system: several Spring Boot services, each with its own database, all of them persisting through JPA. A user logs in once, and from then on every row that user writes should carry their id.&lt;/p&gt;

&lt;p&gt;Some rows, though, are not written by a user at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a nightly job that archives stale records;&lt;/li&gt;
&lt;li&gt;a consumer that picks a command off a queue and executes it;&lt;/li&gt;
&lt;li&gt;an &lt;code&gt;@Async&lt;/code&gt; method that was started inside a request and finishes after the request is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Spring Data has one hook for all of this, &lt;code&gt;AuditorAware&amp;lt;T&amp;gt;&lt;/code&gt;, and it has one method: &lt;code&gt;Optional&amp;lt;T&amp;gt; getCurrentAuditor()&lt;/code&gt;. Most of this article is about what that method should return, and what it should never do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The basic setup
&lt;/h2&gt;

&lt;p&gt;The entity side first, because it does not 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="nd"&gt;@MappedSuperclass&lt;/span&gt;
&lt;span class="nd"&gt;@EntityListeners&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditingEntityListener&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;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditableEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@CreatedBy&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&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;"created_by"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updatable&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="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;createdBy&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@CreatedDate&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&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;"created_at"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updatable&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="kd"&gt;private&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="nd"&gt;@LastModifiedBy&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&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;"updated_by"&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;updatedBy&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@LastModifiedDate&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&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;"updated_at"&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;updatedAt&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// getters only&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things about this class are deliberate choices rather than the defaults you would end up with by accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No setters.&lt;/strong&gt; These fields are written by &lt;code&gt;AuditingEntityListener&lt;/code&gt; and by nobody else. If application code can call &lt;code&gt;setCreatedBy&lt;/code&gt;, sooner or later it will, usually in a "migration" or an "import" that somebody wrote in a hurry. The &lt;code&gt;updatable = false&lt;/code&gt; on the two &lt;code&gt;created_*&lt;/code&gt; columns is the same idea one layer down: even if an entity is loaded, modified and merged back, those two columns are not part of the &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;equals&lt;/code&gt;/&lt;code&gt;hashCode&lt;/code&gt;.&lt;/strong&gt; Do not generate them over these fields. All four change the moment the entity is persisted, so an entity in a &lt;code&gt;HashSet&lt;/code&gt; before &lt;code&gt;save()&lt;/code&gt; is a different object after it. With Lombok that means &lt;code&gt;@Getter&lt;/code&gt; on this class, not &lt;code&gt;@Data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Instant&lt;/code&gt;, not &lt;code&gt;LocalDateTime&lt;/code&gt;.&lt;/strong&gt; The audit trail is going to be read across services that may not share a timezone. &lt;code&gt;Instant&lt;/code&gt; is unambiguous, and Spring Data converts it without configuration.&lt;/p&gt;

&lt;p&gt;Every entity in every service extends this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&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;"note"&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;Note&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AuditableEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Id&lt;/span&gt; &lt;span class="nd"&gt;@GeneratedValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GenerationType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;IDENTITY&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;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;archived&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then turn auditing on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableJpaAuditing&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;JpaAuditingConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire configuration; the auditor is a &lt;code&gt;@Component&lt;/code&gt; implementing &lt;code&gt;AuditorAware&amp;lt;String&amp;gt;&lt;/code&gt;, shown in the next section. Many examples add &lt;code&gt;auditorAwareRef = "auditorAware"&lt;/code&gt; and an explicit &lt;code&gt;@Bean&lt;/code&gt;. The attribute is optional: without it, Spring Data autowires the &lt;code&gt;AuditingHandler&lt;/code&gt; by type, and one &lt;code&gt;AuditorAware&lt;/code&gt; bean in the context is enough. Why it has to be exactly one comes up when the configuration moves into a library.&lt;/p&gt;

&lt;p&gt;One behaviour to know before the first test: on insert, Spring Data fills in &lt;strong&gt;all four&lt;/strong&gt; columns, not just the &lt;code&gt;created_*&lt;/code&gt; pair. &lt;code&gt;@LastModifiedBy&lt;/code&gt; and &lt;code&gt;@LastModifiedDate&lt;/code&gt; are set on creation too, so a freshly inserted row has &lt;code&gt;updated_by == created_by&lt;/code&gt; and &lt;code&gt;updated_at == created_at&lt;/code&gt;. If your reporting logic treats a null &lt;code&gt;updated_at&lt;/code&gt; as "never modified", it is wrong for every row you insert, not just the ones that were later modified. &lt;code&gt;@EnableJpaAuditing(modifyOnCreate = false)&lt;/code&gt; turns this off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the identity comes from
&lt;/h2&gt;

&lt;p&gt;Our services keep the logged-in user in a session-scoped bean:&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;@SessionScope&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;CurrentUser&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Serializable&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;userId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// getter, setter&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One instance per HTTP session, populated at login. How that bean gets populated, and why it is session-scoped at all, is a topic for another article. What matters here is one property of it: the auditor is a singleton, and what it receives is a proxy. Every call on that proxy is routed to the session of the &lt;em&gt;current request thread&lt;/em&gt;. On a thread that is not serving a request, there is no session to route to, and the call throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;org.springframework.beans.factory.support.ScopeNotActiveException:
  Error creating bean with name 'scopedTarget.currentUser':
  Scope 'session' is not active for the current thread; ...
Caused by: java.lang.IllegalStateException: No thread-bound request found:
  Are you referring to request attributes outside of an actual web request,
  or processing a request outside of the originally receiving thread? ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the auditor has to know, before touching the session, whether there is a request at all. &lt;code&gt;RequestContextHolder&lt;/code&gt; answers that question:&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;CurrentUserAuditorAware&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AuditorAware&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="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="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;ANONYMOUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"anonymous"&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="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;USER_PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&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="nc"&gt;CurrentUser&lt;/span&gt; &lt;span class="n"&gt;currentUser&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;CurrentUserAuditorAware&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CurrentUser&lt;/span&gt; &lt;span class="n"&gt;currentUser&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;currentUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentUser&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;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getCurrentAuditor&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;explicit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AuditorContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;current&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;explicit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isPresent&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;explicit&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 request bound to this thread: a scheduled job, a queue consumer, a test.&lt;/span&gt;
        &lt;span class="c1"&gt;// Touching the session-scoped bean here would throw, so we do not.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RequestContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRequestAttributes&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Auditors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SYSTEM&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "system", a constant of the shared library&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofNullable&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentUser&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUserId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;USER_PREFIX&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;orElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ANONYMOUS&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 last statement is the happy path: a request, a session, a user in it. The values are namespaced (&lt;code&gt;user:42&lt;/code&gt;, not &lt;code&gt;42&lt;/code&gt;) so that a user, a job and a consumer can never be mistaken for one another; the full argument is in &lt;em&gt;What goes in the column&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;RequestContextHolder&lt;/code&gt; guard is what prevents the exception above. There is a version of this class that checks &lt;code&gt;currentUser == null&lt;/code&gt; instead; it shows up in a lot of codebases and it is dead code. The proxy is injected at startup and is never null; what can be missing on a given thread is the request that the proxy needs in order to reach a session.&lt;/p&gt;

&lt;p&gt;The distinction between &lt;code&gt;anonymous&lt;/code&gt; and &lt;code&gt;system&lt;/code&gt; is deliberate. A request with nobody logged in and no request at all are different situations, and when you are staring at a row three months later, knowing which one produced it is worth a lot more than the two extra constants cost.&lt;/p&gt;

&lt;p&gt;The first three lines, the explicit auditor, are the subject of the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  When there is no user
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;system&lt;/code&gt; is a correct answer to "who wrote this row" when a job did it. It is a poor answer when the row was written by a consumer executing a command that a specific person put on a queue thirty seconds earlier. And it is a wrong answer when an &lt;code&gt;@Async&lt;/code&gt; method, started inside a request by a logged-in user, finishes its work on a pool thread where &lt;code&gt;RequestContextHolder&lt;/code&gt; is empty. All three cases hit the same &lt;code&gt;return Optional.of(Auditors.SYSTEM)&lt;/code&gt;, and the audit trail cannot tell them apart.&lt;/p&gt;

&lt;p&gt;The fix is to stop treating the auditor as something that has to be &lt;em&gt;discovered&lt;/em&gt; from ambient state, and let the code that actually knows who is acting &lt;em&gt;say so&lt;/em&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;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditorContext&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;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ThreadLocal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;CURRENT&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;ThreadLocal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;runAs&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;auditor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Runnable&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;callAs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auditor&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="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;});&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="nf"&gt;callAs&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;auditor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Supplier&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="no"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auditor&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;try&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;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&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;previous&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="no"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remove&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="no"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofNullable&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CURRENT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="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;A thread-local with a try/finally around it, nothing more. The &lt;code&gt;previous&lt;/code&gt; bookkeeping makes it safe to nest, which matters the first time a job calls a service method that has its own &lt;code&gt;runAs&lt;/code&gt;. On Java 25 you would reach for &lt;code&gt;ScopedValue&lt;/code&gt; instead; the shape of the API is the same.&lt;/p&gt;

&lt;p&gt;The auditor checks this first, before looking at any request. An explicit statement of who is acting beats an inference from ambient state.&lt;/p&gt;

&lt;p&gt;Now the three cases become three different rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scheduled job&lt;/strong&gt; names itself:&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;NightlyArchiveJob&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="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;AUDITOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"job:nightly-archive"&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;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="no"&gt;RETENTION&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="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NoteService&lt;/span&gt; &lt;span class="n"&gt;noteService&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;NightlyArchiveJob&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoteService&lt;/span&gt; &lt;span class="n"&gt;noteService&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;noteService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;noteService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cron&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0 0 3 * * *"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"UTC"&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;run&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;archiveCreatedBefore&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="no"&gt;RETENTION&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;archiveCreatedBefore&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;cutoff&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AuditorContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;callAs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;AUDITOR&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;noteService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;archiveEachCreatedBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cutoff&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;archiveEachCreatedBefore&lt;/code&gt; does what the name says: it loads the stale notes and flips them one at a time, so each row goes through the auditing listener and gets &lt;code&gt;updated_by = job:nightly-archive&lt;/code&gt;. That is a row you can act on directly, whereas a row that says &lt;code&gt;system&lt;/code&gt; sends you to the logs to find out which process wrote it.&lt;/p&gt;

&lt;p&gt;A side note on the &lt;code&gt;zone&lt;/code&gt; attribute, since it is the kind of thing that only bites in production: a cron expression runs in the JVM's default time zone unless you say otherwise. In a container that is usually UTC; on the laptop where you tested it, it is not. Spelling it out costs nothing and removes a whole category of "why did the job run at 4" questions. The cutoff itself is an &lt;code&gt;Instant&lt;/code&gt;, so the comparison has no time zone to get wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The queue consumer&lt;/strong&gt; carries the originating user through. The producer knew who asked for this; it is a header on the message, or a field in the payload. The consumer's only job is not to lose it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="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;NoteCommandConsumer&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="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;AUDITOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"consumer:note-commands"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// requestedBy is already namespaced upstream (e.g. "user:42")&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;NoteCommand&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requestedBy&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NoteService&lt;/span&gt; &lt;span class="n"&gt;noteService&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;NoteCommandConsumer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoteService&lt;/span&gt; &lt;span class="n"&gt;noteService&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;noteService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;noteService&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;Note&lt;/span&gt; &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoteCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;auditor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestedBy&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestedBy&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;AUDITOR&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AuditorContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;callAs&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auditor&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;noteService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the message has no origin, the consumer falls back to its own name (&lt;code&gt;consumer:note-commands&lt;/code&gt;) rather than to &lt;code&gt;system&lt;/code&gt;, so the row still says something specific about where it came from.&lt;/p&gt;

&lt;p&gt;One caveat that has to be stated: &lt;code&gt;requestedBy&lt;/code&gt; is a value the producer controls. This only works if the producer is trusted, which in practice means the queue is internal and the message was authenticated on the way in. An auditor taken from an unauthenticated payload is a spoofable audit trail.&lt;/p&gt;

&lt;p&gt;The value also arrives already namespaced, because normalising an identity into &lt;code&gt;user:42&lt;/code&gt; belongs at the boundary where it enters the system, not in every consumer that happens to handle it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;@Async&lt;/code&gt; method&lt;/strong&gt; is the same problem in a different costume. Thread-locals do not cross thread boundaries, and that applies equally to &lt;code&gt;RequestContextHolder&lt;/code&gt;, to &lt;code&gt;SecurityContextHolder&lt;/code&gt; and to our own &lt;code&gt;AuditorContext&lt;/code&gt;. Capture the auditor before the hop and re-establish it on the other side, either by hand (&lt;code&gt;String who = ...; executor.submit(() -&amp;gt; AuditorContext.runAs(who, ...))&lt;/code&gt;) or with a &lt;code&gt;TaskDecorator&lt;/code&gt; on the executor that does it for every task. We will not go further into that here. The point is that once the auditor is a value you can hold in a variable, propagating it becomes a solved problem, which it never was while the identity stayed buried in a session proxy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The test that lied to us
&lt;/h3&gt;

&lt;p&gt;The two test classes in the repo are split on purpose: one goes through MockMvc with a real session, the other has no request at all. The second one is where the &lt;code&gt;system&lt;/code&gt; branch gets exercised.&lt;/p&gt;

&lt;p&gt;Our first version of it was a plain &lt;code&gt;@SpringBootTest&lt;/code&gt; calling the service directly. The row came back &lt;code&gt;anonymous&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not &lt;code&gt;system&lt;/code&gt;. &lt;code&gt;anonymous&lt;/code&gt;. Which means the auditor found a request bound to the test thread, found no user on it, and gave the right answer for what it saw. Where did the request come from?&lt;/p&gt;

&lt;p&gt;From Spring itself. When a test's context is a &lt;code&gt;WebApplicationContext&lt;/code&gt; (the default for &lt;code&gt;@SpringBootTest&lt;/code&gt; in a web project), &lt;code&gt;ServletTestExecutionListener&lt;/code&gt; binds a &lt;code&gt;MockHttpServletRequest&lt;/code&gt; to the test thread before every test method. The intent is that code depending on &lt;code&gt;RequestContextHolder&lt;/code&gt; works in tests without MockMvc. The side effect is that a test meant to simulate "no request" is not simulating it at all, and the branch you wrote the test for never runs.&lt;/p&gt;

&lt;p&gt;The fix is one attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webEnvironment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SpringBootTest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WebEnvironment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NONE&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;AuditingOutsideRequestTest&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;With no web layer in the context, the listener does not run and nothing gets bound to the thread. That is also what a batch worker's context looks like, and it is the situation this test is meant to cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes in the column
&lt;/h2&gt;

&lt;p&gt;The values so far have been strings like &lt;code&gt;user:42&lt;/code&gt; and &lt;code&gt;job:nightly-archive&lt;/code&gt;. That is a decision, and one that is hard to undo once a few million rows carry it.&lt;/p&gt;

&lt;p&gt;The temptation is to store the user's primary key. It is what you have at hand and what the rest of the schema uses, and &lt;code&gt;AuditorAware&amp;lt;Long&amp;gt;&lt;/code&gt; makes it look like the intended choice. Then the first scheduled job runs and you need a value for it. If the column is a &lt;code&gt;BIGINT&lt;/code&gt;, you invent one: &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;-1&lt;/code&gt;, a "system user" row. If the column is a &lt;code&gt;VARCHAR&lt;/code&gt; that happens to always contain digits, you write &lt;code&gt;system&lt;/code&gt; into it, and somewhere downstream a frontend or a report does &lt;code&gt;Long.parseLong(createdBy)&lt;/code&gt; and falls over, though not immediately: it happens weeks later, on the one screen that happens to list rows a job touched.&lt;/p&gt;

&lt;p&gt;The rule we landed on is boring: &lt;strong&gt;one column, one kind of value, and never mix kinds.&lt;/strong&gt; Two ways to honour it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is a user.&lt;/strong&gt; The column is a foreign key to the users table, and every non-human actor is a real row in it: a technical user for each job, and likewise for each consumer and each integration that writes rows. &lt;code&gt;job:nightly-archive&lt;/code&gt; becomes user 10001, with a display name, and the frontend resolves it like any other user without special cases. This is the cleanest option if you already have a users table in every service and you can afford to seed it with the technical accounts. It is also the one where &lt;code&gt;AuditorAware&amp;lt;Long&amp;gt;&lt;/code&gt; is the accurate type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is a string, and the string says what it is.&lt;/strong&gt; The column is text, the values are &lt;code&gt;user:42&lt;/code&gt;, &lt;code&gt;job:nightly-archive&lt;/code&gt;, &lt;code&gt;consumer:note-commands&lt;/code&gt;, &lt;code&gt;system&lt;/code&gt;, &lt;code&gt;anonymous&lt;/code&gt;, and no code anywhere is allowed to parse a number out of it. The namespace is the guard: nobody looks at &lt;code&gt;user:42&lt;/code&gt; and thinks it is an integer. This is what the demo does, because it needs no users table and it works across services that do not share one.&lt;/p&gt;

&lt;p&gt;What you should not do is the thing that happens by default: a string column that contains a number for people and a word for everyone else. That is a &lt;code&gt;Long&lt;/code&gt; with a lie in it, and every consumer of the column has to know about the lie.&lt;/p&gt;

&lt;p&gt;Whichever you pick, store a stable identifier rather than a display name, because names change over time, they get corrected, and sometimes they get anonymised, and none of that should propagate into an audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing it across microservices
&lt;/h2&gt;

&lt;p&gt;Everything above lives in one service. We have a dozen, and they all need the same four columns and the same &lt;code&gt;AuditableEntity&lt;/code&gt;, governed by the same rules. The natural move is a shared library, and the natural mistake is to put &lt;code&gt;AuditableEntity&lt;/code&gt; in the library and leave each service to write its own configuration.&lt;/p&gt;

&lt;p&gt;What the library should own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AuditableEntity&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Auditors&lt;/code&gt;, the values the library itself knows about (&lt;code&gt;system&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AuditorContext&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; itself&lt;/li&gt;
&lt;li&gt;a default &lt;code&gt;AuditorAware&lt;/code&gt; for services that have no notion of a user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What each service should own: its &lt;code&gt;AuditorAware&lt;/code&gt;, &lt;em&gt;if&lt;/em&gt; it has something better than the default to say. That is the whole contract.&lt;/p&gt;

&lt;p&gt;In Spring Boot terms, the library ships an auto-configuration:&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;@AutoConfiguration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableJpaAuditing&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;AuditingAutoConfiguration&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nd"&gt;@ConditionalOnMissingBean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditorAware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AuditorAware&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;systemAuditorAware&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="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditorContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;current&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;Auditors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SYSTEM&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;registered in &lt;code&gt;META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;com.example.auditdemo.audit.AuditingAutoConfiguration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A web-facing service keeps its &lt;code&gt;CurrentUserAuditorAware&lt;/code&gt; as a &lt;code&gt;@Component&lt;/code&gt; and deletes its &lt;code&gt;JpaAuditingConfig&lt;/code&gt;; the &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; now lives in the library. Auto-configurations are evaluated after the service's own beans, so &lt;code&gt;@ConditionalOnMissingBean&lt;/code&gt; sees the service's bean and the default steps aside. A batch worker with no session does not need to declare anything: it gets the default, and its rows say whatever &lt;code&gt;AuditorContext&lt;/code&gt; was told, or &lt;code&gt;system&lt;/code&gt; when it was told nothing. No service ever writes &lt;code&gt;@EnableJpaAuditing&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That last sentence is a rule, and Spring enforces it at startup. &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; registers its infrastructure beans by fixed name, and Boot has refused to silently override bean definitions since 2.1. A service that keeps its old configuration alongside the library's auto-configuration does not start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BeanDefinitionOverrideException: Invalid bean definition with name 'jpaAuditingHandler'
  defined in null: Cannot register bean definition [...] for bean 'jpaAuditingHandler'
  since there is already [...] bound.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;defined in null&lt;/code&gt; is the tell, because it means the bean was registered by an annotation's registrar rather than by a class you can open in the IDE. When you see this one, the search term is &lt;code&gt;@EnableJpaAuditing&lt;/code&gt;, and the answer is that it is declared twice.&lt;/p&gt;

&lt;p&gt;The same exception shows up in a second disguise. If an &lt;code&gt;AuditorAware&lt;/code&gt; implementation is annotated &lt;code&gt;@Component("auditorAware")&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; a configuration class has a &lt;code&gt;@Bean&lt;/code&gt; method called &lt;code&gt;auditorAware&lt;/code&gt; returning it (a common combination, because the &lt;code&gt;@Bean&lt;/code&gt; version gets added when someone reads about &lt;code&gt;auditorAwareRef&lt;/code&gt; and nobody removes the &lt;code&gt;@Component&lt;/code&gt;), the two definitions collide on the name, and one of them has to go; the &lt;code&gt;@Component&lt;/code&gt; on its own is enough.&lt;/p&gt;

&lt;p&gt;The demo repository is a single module, but the &lt;code&gt;audit&lt;/code&gt; package in it is wired exactly this way, imports file included, so you can see the mechanism working without a second &lt;code&gt;pom.xml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The null column
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;getCurrentAuditor()&lt;/code&gt; returns an &lt;code&gt;Optional&lt;/code&gt; for a reason: &lt;code&gt;Optional.empty()&lt;/code&gt; means "unknown", and Spring Data leaves the column untouched. On insert, that means &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Whether that is acceptable is a schema question. With &lt;code&gt;created_by NOT NULL&lt;/code&gt;, an empty auditor turns into a constraint violation at flush time, several stack frames away from the auditor and with a message about the table rather than about the cause. We went with a sentinel (&lt;code&gt;system&lt;/code&gt;) instead of &lt;code&gt;empty()&lt;/code&gt; for that reason, and because a &lt;code&gt;NULL&lt;/code&gt; in an audit column is one more thing that "cannot happen" that will happen.&lt;/p&gt;

&lt;p&gt;Two related traps. Returning &lt;code&gt;null&lt;/code&gt; instead of an &lt;code&gt;Optional&lt;/code&gt; fails immediately with &lt;code&gt;Auditor must not be null&lt;/code&gt;, which is at least loud. And a user object that exists but has a null id (a half-initialised session) will produce the string &lt;code&gt;"null"&lt;/code&gt; if you build the value with &lt;code&gt;String.valueOf&lt;/code&gt;. Check that the id is present as well as the object before building the value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bulk queries
&lt;/h3&gt;

&lt;p&gt;This one is not a bug so much as a property of how JPA works, and it seems to catch everyone once. The nightly job above archives one entity at a time. Here is the same operation written as a bulk update 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="nd"&gt;@Modifying&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"update Note n set n.archived = true where n.archived = false and n.createdAt &amp;lt; :before"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;bulkArchiveCreatedBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"before"&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;before&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A JPQL or native &lt;code&gt;UPDATE&lt;/code&gt; goes straight to the database. No entity is loaded, no &lt;code&gt;@PreUpdate&lt;/code&gt; fires, and the audit columns keep whatever they had. Run the job this way and it produces rows that say nothing about it: the test in the repo shows the &lt;code&gt;archived&lt;/code&gt; flag flipping while &lt;code&gt;updated_by&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt; do not move. &lt;code&gt;deleteAllInBatch&lt;/code&gt; and every native query are the same story.&lt;/p&gt;

&lt;p&gt;Your options, in the order we usually pick them: load the entities and modify them one by one, as the job does (correct, slow past a few thousand rows); set the audit columns explicitly in the query (&lt;code&gt;set n.archived = true, n.updatedBy = :who, n.updatedAt = :now&lt;/code&gt;), which works but duplicates the auditor logic in every bulk query; or accept that bulk operations are unaudited at the row level and log them as one event at the job level. The wrong option is not deciding, which is what happens by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Listeners that do nothing
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@EntityListeners(AuditingEntityListener.class)&lt;/code&gt; on &lt;code&gt;AuditableEntity&lt;/code&gt; is what makes any of this happen, and it is inherited by every subclass, so you write it once. But the listener is only a hook. It delegates to an &lt;code&gt;AuditingHandler&lt;/code&gt; that &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; creates and injects, which works because Boot hands Hibernate a Spring-aware bean container, so the listener Hibernate instantiates is actually a Spring bean.&lt;/p&gt;

&lt;p&gt;Take &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; away and nothing fails. The listener still runs, but it finds no handler and simply returns, so every audit column on every row is &lt;code&gt;NULL&lt;/code&gt; and there is not a single log line to say why. We reproduced it in the demo by excluding the auto-configuration: &lt;code&gt;createdBy=null createdAt=null&lt;/code&gt;, test green.&lt;/p&gt;

&lt;p&gt;This is the failure mode to expect in a new service that pulled in the library but is missing something. The checklist, in order: is the listener on the entity or its superclass? Is &lt;code&gt;@EnableJpaAuditing&lt;/code&gt; present, exactly once? Is there exactly one &lt;code&gt;AuditorAware&lt;/code&gt; bean? The first two fail silently; the third fails at startup, which is a mercy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not cover
&lt;/h2&gt;

&lt;p&gt;Row-level audit columns tell you who last touched a row. They do not tell you what it looked like before, and they cannot be trusted to record every touch, as the bulk-query section shows. A real audit log is a separate concern: Hibernate Envers, a history table maintained by triggers, or change data capture off the transaction log. The four columns are the cheap first layer, and they are worth having precisely because they are cheap.&lt;/p&gt;

&lt;p&gt;What they are not worth having is half-configured. An auditor that returns &lt;code&gt;system&lt;/code&gt; from every path it does not understand will pass every test you write on a request thread, and then spend a year writing &lt;code&gt;system&lt;/code&gt; into rows that a specific person, or a specific job, was responsible for. The fix is not more fallback logic. It is making the code that knows who is acting say so, and making the fallback the exception it was supposed to be.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>jpa</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Where OpenTelemetry Stops: the missing 44ms in a Spring Boot trace</title>
      <dc:creator>Paolo</dc:creator>
      <pubDate>Fri, 31 Jul 2026 12:19:47 +0000</pubDate>
      <link>https://dev.to/purbano/where-opentelemetry-stops-the-missing-44ms-in-a-spring-boot-trace-ld9</link>
      <guid>https://dev.to/purbano/where-opentelemetry-stops-the-missing-44ms-in-a-spring-boot-trace-ld9</guid>
      <description>&lt;p&gt;A POST /orders in this demo takes 178 milliseconds. The OpenTelemetry Java agent, attached with a single JVM flag and no code changes, will tell you that 127 of those went to a downstream service and 6 to the database. It will not tell you anything about the remaining 44.&lt;/p&gt;

&lt;p&gt;That gap is where auto-instrumentation ends and your own code begins. This article locates that boundary precisely, then crosses it: with distributed tracing across two Spring Boot services with zero code changes, then with the minimum instrumentation needed to make the missing 44 milliseconds visible.&lt;/p&gt;

&lt;p&gt;Everything is in a companion repository: &lt;a href="https://github.com/PaoloUrbano/spring-boot-otel-demo" rel="noopener noreferrer"&gt;&lt;strong&gt;spring-boot-otel-demo&lt;/strong&gt;&lt;/a&gt;. If you have Docker installed you can clone it and have a live trace in front of you in about two minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versions used:&lt;/strong&gt; OpenTelemetry Java agent 2.30.0, &lt;code&gt;opentelemetry-instrumentation-annotations&lt;/code&gt; 2.30.0, Spring Boot 3.5.16, Java 21, &lt;code&gt;otel/opentelemetry-collector-contrib:0.157.0&lt;/code&gt;, &lt;code&gt;jaegertracing/all-in-one:1.76.0&lt;/code&gt;, &lt;code&gt;postgres:18-alpine&lt;/code&gt;. This ecosystem moves fast. Check the current OpenTelemetry Java documentation before copying any version number.&lt;/p&gt;

&lt;p&gt;The repository has two branches on purpose. &lt;code&gt;master&lt;/code&gt; is the agent-only state described in Step 1: no OpenTelemetry dependency in either service. &lt;code&gt;manual-spans&lt;/code&gt; is the annotated version from Step 2. Clone, run &lt;code&gt;master&lt;/code&gt; first, then switch branches to see the difference.&lt;/p&gt;

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

&lt;p&gt;Here is a request that touches two services and a database.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /orders&lt;/code&gt; arrives at &lt;code&gt;order-service&lt;/code&gt;. The service validates the request, calculates pricing, calls &lt;code&gt;inventory-service&lt;/code&gt; over HTTP to check stock, persists the order to PostgreSQL, and returns a response.&lt;/p&gt;

&lt;p&gt;It takes around 180 milliseconds. Sometimes it takes 900. Where does the time go?&lt;/p&gt;

&lt;p&gt;The usual answer is to open the logs of both services, filter by timestamp, and reconstruct the sequence by hand. That works with two services. It does not work with eight, and it does not work at all when two requests overlap in the same window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traces, spans, and the one header that matters
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;trace&lt;/strong&gt; is one request through your whole system. A &lt;strong&gt;span&lt;/strong&gt; is one unit of work inside it: an HTTP handler, a database query, a method call. Spans nest: each has a parent, and the trace is a tree.&lt;/p&gt;

&lt;p&gt;Every span in a trace carries the same &lt;strong&gt;trace id&lt;/strong&gt;. That is what lets a UI stitch together work that happened in different processes, on different machines.&lt;/p&gt;

&lt;p&gt;The mechanism for carrying that id across a service boundary is a single HTTP header, &lt;code&gt;traceparent&lt;/code&gt;, defined by the W3C Trace Context specification. &lt;code&gt;order-service&lt;/code&gt; sends it, &lt;code&gt;inventory-service&lt;/code&gt; reads it, and the second service knows it belongs to a trace that started somewhere else.&lt;/p&gt;

&lt;p&gt;Here is the setup we are building:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0pd03rzedfcy6otdwco.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%2Ff0pd03rzedfcy6otdwco.png" alt="Otel architecture" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note the shape. The two services never talk to Jaeger. They talk to an &lt;strong&gt;OpenTelemetry Collector&lt;/strong&gt;, and the Collector decides where the data goes. We will come back to why that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: tracing with zero code changes
&lt;/h2&gt;

&lt;p&gt;The OpenTelemetry Java agent attaches to the JVM and rewrites bytecode at class-loading time. It recognises Spring MVC, JDBC, Hibernate and HTTP clients, and creates spans for them without the application knowing.&lt;/p&gt;

&lt;p&gt;Download the agent jar in your Dockerfile, then start the service with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["java", "-javaagent:/app/opentelemetry-javaagent.jar", "-jar", "/app/app.jar"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure it with environment variables in &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;OTEL_SERVICE_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;order-service&lt;/span&gt;
  &lt;span class="na"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://otel-collector:4317&lt;/span&gt;
  &lt;span class="na"&gt;OTEL_EXPORTER_OTLP_PROTOCOL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grpc&lt;/span&gt;
  &lt;span class="na"&gt;OTEL_TRACES_EXPORTER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otlp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The protocol line is load-bearing, not decoration. Port 4317 is the gRPC port and the Collector in this repo opens a gRPC receiver only. Defaults for this variable have varied across SDKs and versions, so set it explicitly: if the agent picks &lt;code&gt;http/protobuf&lt;/code&gt; and sends it to 4317, every export fails and no traces reach Jaeger.&lt;/p&gt;

&lt;p&gt;The failure is at least loud. The agent logs it to the service's own stdout on every export cycle, naming both the exporter and the endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[otel.javaagent] ... ERROR io.opentelemetry.exporter.otlp.internal.HttpExporter
- Failed to export spans. The request could not be executed.
java.net.SocketException: Connection reset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Jaeger is empty, &lt;code&gt;docker compose logs order-service&lt;/code&gt; is the first place to look. The word &lt;code&gt;HttpExporter&lt;/code&gt; against a port you configured for gRPC is the whole diagnosis.&lt;/p&gt;

&lt;p&gt;That is the entire integration. No dependency in &lt;code&gt;pom.xml&lt;/code&gt;, no configuration class, no SDK.&lt;/p&gt;

&lt;p&gt;Bring the stack up and send a request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"customerId":"CUST-001","sku":"SKU-1000","quantity":5}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open Jaeger at &lt;code&gt;http://localhost:16686&lt;/code&gt;, select &lt;code&gt;order-service&lt;/code&gt;, and click Find Traces:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgs2txkuvj37shhel4itn.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%2Fgs2txkuvj37shhel4itn.png" alt="Agent only" width="800" height="86"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One thing before you read anything into the numbers: let the JVM warm up. The first request through a fresh service can be three times slower than the steady state, and the shape of that first trace is misleading. Send the request ten times and look at the last one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you got for free
&lt;/h2&gt;

&lt;p&gt;Read that waterfall carefully, because there is more in it than it first appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trace crosses the service boundary.&lt;/strong&gt; &lt;code&gt;inventory-service GET /inventory/{sku}&lt;/code&gt; is nested inside the client span of &lt;code&gt;order-service&lt;/code&gt;. Two JVMs, two containers, one trace. Nobody wrote code to make that happen. The agent injected &lt;code&gt;traceparent&lt;/code&gt; on the way out and read it on the way in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The persistence layer is broken down for you.&lt;/strong&gt; &lt;code&gt;OrderRepository.save&lt;/code&gt; at 5.93ms contains &lt;code&gt;Session.persist&lt;/code&gt; at 1.36ms, the actual &lt;code&gt;INSERT&lt;/code&gt; at 496µs, and &lt;code&gt;Transaction.commit&lt;/code&gt; at 3.37ms.&lt;/p&gt;

&lt;p&gt;That last pair is worth sitting with. The &lt;code&gt;INSERT&lt;/code&gt; is under half a millisecond. The commit costs nearly seven times as much. Most of the time spent persisting this order is not the query. It is everything around it. You would never learn that from a log line, and it is the kind of detail that changes how you think about transaction boundaries under load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self time versus total time.&lt;/strong&gt; The client span for the inventory call is 127.37ms, while the server span inside &lt;code&gt;inventory-service&lt;/code&gt; is 123.69ms. The difference is real client-side and network overhead. When hunting a bottleneck, the number you want is not the widest bar, but the bar that is wide &lt;em&gt;and does not have equally wide children&lt;/em&gt;. A service that takes two seconds is not guilty if 1.9 of them are spent waiting for someone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the agent stops
&lt;/h2&gt;

&lt;p&gt;Now look at the start of the trace:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsf7s5et1zty0smwat8qv.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%2Fsf7s5et1zty0smwat8qv.png" alt="Agent only zoom" width="800" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The root span starts at zero. The first child span starts around 44 milliseconds later. Nothing at all is recorded in between.&lt;/p&gt;

&lt;p&gt;That gap is your business logic. In this service it is request validation followed by a pricing calculation: plain Java, no I/O. The DTO mapping that happens after persistence is invisible for the same reason, though at well under a millisecond it does not leave a hole you can see.&lt;/p&gt;

&lt;p&gt;And that is exactly why the agent cannot see them. Auto-instrumentation works by recognising &lt;strong&gt;libraries&lt;/strong&gt;: a servlet container, a JDBC driver, an HTTP client. It has no way to know that &lt;code&gt;computePricing&lt;/code&gt; is a meaningful unit of work in your domain. From the agent's point of view, the time between two library calls is just time.&lt;/p&gt;

&lt;p&gt;This is the honest limit of "zero code changes". The agent gives you the skeleton of the request for free. The flesh is your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: naming your own work
&lt;/h2&gt;

&lt;p&gt;Add one dependency to &lt;code&gt;order-service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;properties&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Must match the agent version pinned in the Dockerfile --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;opentelemetry-instrumentation.version&amp;gt;&lt;/span&gt;2.30.0&lt;span class="nt"&gt;&amp;lt;/opentelemetry-instrumentation.version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/properties&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;io.opentelemetry.instrumentation&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;opentelemetry-instrumentation-annotations&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;${opentelemetry-instrumentation.version}&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep that version aligned with the agent jar you download in the Dockerfile. A mismatch between the two is the most common reason &lt;code&gt;@WithSpan&lt;/code&gt; spans silently fail to appear. The build succeeds, the service starts, and the spans simply are not there.&lt;/p&gt;

&lt;p&gt;Then annotate the methods that matter. &lt;code&gt;@WithSpan&lt;/code&gt; creates a span for the method, correctly parented to whatever span is active. &lt;code&gt;@SpanAttribute&lt;/code&gt; attaches a method parameter to that span as a searchable field:&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;@WithSpan&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"calculate-pricing"&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;PricingBreakdown&lt;/span&gt; &lt;span class="nf"&gt;computePricing&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@SpanAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order.sku"&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;sku&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                        &lt;span class="nd"&gt;@SpanAttribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order.quantity"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// both helpers are plain in-memory work: a price list scan and a discount tier walk&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;basePrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lookupBasePrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;discountRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolveVolumeDiscountRate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;subtotalAfterDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ZERO&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;taxAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ZERO&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;remainingUnits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remainingUnits&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;unitsInLine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LINE_SIZE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remainingUnits&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineSubtotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;applyPerUnitRules&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePrice&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unitsInLine&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineSubtotal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multiply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;discountRate&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setScale&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;RoundingMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HALF_UP&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineAfterDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineSubtotal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subtract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lineDiscount&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineTax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineAfterDiscount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multiply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TAX_RATE&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setScale&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;RoundingMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HALF_UP&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;subtotalAfterDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subtotalAfterDiscount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lineAfterDiscount&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;taxAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;taxAmount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lineTax&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;remainingUnits&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;unitsInLine&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;totalPrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subtotalAfterDiscount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taxAmount&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setScale&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;RoundingMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HALF_UP&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;PricingBreakdown&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePrice&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;discountRate&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;taxAmount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;totalPrice&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 I/O, no sleeps, no database. Pure CPU work, which is exactly the point. This is the kind of method the agent cannot see.&lt;/p&gt;

&lt;p&gt;One disclosure, since the numbers have to add up: the loop you see here is cheap at &lt;code&gt;quantity = 5&lt;/code&gt;. The weight sits in &lt;code&gt;lookupBasePrice&lt;/code&gt;, which builds and linearly scans a price catalog of roughly 950,000 entries, once per call rather than once per unit. That is deliberately absurd. In a real service it would be a cached lookup and the span would measure microseconds. The point of this article is the shape of the trace, not the size of the number, and a gap of forty milliseconds is easier to see in a screenshot than a gap of forty microseconds.&lt;/p&gt;

&lt;p&gt;Two things worth knowing before scattering these around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It works on private methods, but only because the agent is rewriting bytecode.&lt;/strong&gt; This is not a Spring proxy, so there is no self-invocation problem to worry about. A private method called from within the same class still gets its span. If one fails to appear, check the version alignment above first, then try widening the method's visibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extract before you annotate.&lt;/strong&gt; A span is only useful if it wraps one coherent thing. If your pricing logic sits inline in the middle of a 200-line service method, annotating that method tells you nothing you did not already know.&lt;/p&gt;

&lt;p&gt;Switch to the &lt;code&gt;manual-spans&lt;/code&gt; branch, rebuild, send the same request, and the gap is gone:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0ydnzwif3o01wjh1e6z.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%2Fj0ydnzwif3o01wjh1e6z.png" alt="Manual-spans" width="800" height="161"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;validate-order-request&lt;/code&gt; at 32µs. &lt;code&gt;calculate-pricing&lt;/code&gt; at 38.68ms, most of the 44ms gap from before, now with a name. &lt;code&gt;check-inventory&lt;/code&gt; and &lt;code&gt;persist-order&lt;/code&gt; wrap the library spans they own, and &lt;code&gt;map-response&lt;/code&gt; closes the request at 20µs.&lt;/p&gt;

&lt;p&gt;Note that a few milliseconds are still unaccounted for. That residue is not a failure of instrumentation: some of what sits between two spans is request deserialisation, framework dispatch and other work that is not yours to name. You are never going to close the gap completely, and chasing the last microsecond is how you end up with a trace so noisy that nobody reads it.&lt;/p&gt;

&lt;p&gt;The totals: 178.06ms without manual spans, 184.86ms with them. Do not read that difference as the cost of instrumentation. Two annotations cannot cost seven milliseconds. The runtime overhead of a span is a context push and a timestamp, measured in fractions of a microsecond. What you are looking at is JVM noise: a minor GC, a scheduling hiccup, network jitter between two containers. Run the same request twenty times and you will see a wider spread than this with no code change at all.&lt;/p&gt;

&lt;p&gt;There is one real cost worth naming: annotating a small private method can prevent the JIT from inlining it, so a hot, tight method may genuinely run slower once instrumented. That is an argument for instrumenting meaningful units of work rather than every method you can reach. It is not an argument against instrumenting at all. In an I/O bound service like this one, where a single HTTP call accounts for 70% of the request, it is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attributes are where this gets useful
&lt;/h2&gt;

&lt;p&gt;Click on the &lt;code&gt;calculate-pricing&lt;/code&gt; span:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qdo76kzjjbiuim20k5c.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%2F8qdo76kzjjbiuim20k5c.png" alt="Span attributes" width="799" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The span carries &lt;code&gt;order.sku = SKU-1000&lt;/code&gt; and &lt;code&gt;order.quantity = 5&lt;/code&gt;, the actual values from the request. It also carries &lt;code&gt;code.function = computePricing&lt;/code&gt; and &lt;code&gt;code.namespace = com.example.orderservice.order.OrderService&lt;/code&gt;, added automatically.&lt;/p&gt;

&lt;p&gt;This is the difference between &lt;em&gt;something took 38ms&lt;/em&gt; and &lt;em&gt;&lt;code&gt;OrderService.computePricing&lt;/code&gt; took 38ms for SKU-1000 at quantity 5&lt;/em&gt;. Once those attributes are on the span you can search on them: every pricing calculation above a threshold, filtered by SKU. That is a question you cannot ask a log file without writing a parser first.&lt;/p&gt;

&lt;p&gt;Two cautions. Whatever you put in an attribute ends up in your observability backend, so keep personal data and secrets out of them. And be careful about where that attribute travels: &lt;code&gt;order.sku&lt;/code&gt; is harmless on a span, because traces are sampled and stored as individual records. Put the same field on a &lt;strong&gt;metric&lt;/strong&gt; and you have created one time series per SKU. High-cardinality labels are the classic way to take down a Prometheus instance, and the mistake is easy to make once you start instrumenting by habit rather than by intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Collector is in the picture
&lt;/h2&gt;

&lt;p&gt;You may have noticed the agent could have pointed straight at Jaeger. In development it can. There are three reasons not to do that anywhere else.&lt;/p&gt;

&lt;p&gt;Batching and retries belong in a separate process, not in the request path of your application. The Collector can enrich, filter and drop data centrally rather than in every service. And most importantly:&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;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jaeger:4317&lt;/span&gt;
    &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing your observability backend means editing that block. It does not mean touching, rebuilding or redeploying a single service. Your applications emit OTLP, an open protocol, and where it lands is a deployment detail.&lt;/p&gt;

&lt;p&gt;If you have ever had to argue an architecture decision on vendor lock-in grounds, this is an unusually clean example: the instrumentation is standard, the wire protocol is standard, and the vendor-specific part is one block in one file.&lt;/p&gt;

&lt;p&gt;For completeness: OTLP travels over gRPC on port 4317 and over HTTP/protobuf on 4318. The specification says the default should be &lt;code&gt;http/protobuf&lt;/code&gt; unless an SDK has good reason to pick gRPC (backward compatibility, typically), so what you actually get depends on your SDK and version. Pin it explicitly, as in the compose file above. gRPC is generally the right choice for agent-to-collector traffic, which is high volume and continuous. Switch to HTTP if you have proxies or load balancers in between that are unhappy with long-lived HTTP/2 connections, which is common enough in enterprise networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not cover
&lt;/h2&gt;

&lt;p&gt;Tracing every request is affordable on a laptop but not in production, so sampling strategy is the decision waiting on the other side of this setup. Traces are also only one of three signals: metrics and logs travel the same pipeline, and correlating existing Log4j2 output with trace ids is arguably the highest-value thing to do after this. Asynchronous boundaries deserve their own article: put a message on a queue and the context does not propagate on its own, so the trace breaks silently at exactly that point.&lt;/p&gt;

&lt;p&gt;The boundary described here, though, does not move. Auto-instrumentation maps the parts of your system that are made of libraries. Everything that is genuinely yours (the logic you were actually hired to write) stays invisible until you name it.&lt;/p&gt;

&lt;p&gt;Naming it costs one dependency and a handful of annotations.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>opentelemetry</category>
      <category>observability</category>
    </item>
  </channel>
</rss>
