<?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: Axelix Labs</title>
    <description>The latest articles on DEV Community by Axelix Labs (axelix-labs).</description>
    <link>https://dev.to/axelix-labs</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%2Forganization%2Fprofile_image%2F13959%2Fdcb928d4-7e87-4833-96bf-7def9f88f907.png</url>
      <title>DEV Community: Axelix Labs</title>
      <link>https://dev.to/axelix-labs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/axelix-labs"/>
    <language>en</language>
    <item>
      <title>The Tragedy of Software Versioning</title>
      <dc:creator>Sergei Cherkasov</dc:creator>
      <pubDate>Mon, 21 Sep 2026 15:13:25 +0000</pubDate>
      <link>https://dev.to/axelix-labs/the-tragedy-of-software-versioning-602</link>
      <guid>https://dev.to/axelix-labs/the-tragedy-of-software-versioning-602</guid>
      <description>&lt;p&gt;Software versioning goes far beyond SemVer. A practical guide to CalVer, marketing versions, lockstep releases, pain indexes, and compatibility windows.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;As we say, two of the most complicated problems in software are naming things and versioning software&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Thomas Wuerthinger, the GraalVM project lead, once said something along those lines in the corridors of Devoxx Belgium while explaining the details of &lt;a href="https://blogs.oracle.com/java/detaching-graalvm-from-the-java-ecosystem-train" rel="noopener noreferrer"&gt;this much-discussed article&lt;/a&gt;. It is not an exact quote, just a phrase meant to draw attention to the problem. And that problem is what we are going to discuss today.&lt;/p&gt;

&lt;p&gt;Hi everyone! My name is Mikhail Polivakha, and I am the technical lead of the Open Source &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;Axelix project&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I have wanted to write a short article about software versioning in general for quite a while because, in my view, the subject gets far less attention than it deserves. For many people, versioning begins and ends with SemVer, but the topic is much, much broader than it might seem.&lt;/p&gt;

&lt;p&gt;This article will be useful to technical leads and those aiming for a Staff Engineer position, because your software versioning strategy, its relationship to your release cycle, and the nature of your software updates are very, &lt;em&gt;very&lt;/em&gt; important product decisions. Getting them wrong will be costly. Should you use a Release Train? Should you work in lockstep? Do you need a Compatibility Window? Do you need a compatibility matrix? And so on.&lt;/p&gt;

&lt;p&gt;You may want to bookmark this article and return to it from time to time. Without further preamble, let us begin.&lt;/p&gt;

&lt;h2&gt;
  
  
  In the Beginning Was the Word
&lt;/h2&gt;

&lt;p&gt;Our software evolves. We add new capabilities, fix bugs, learn new things about the domain, and discover how users actually use the product. Even if a product is functionally complete, one day it will still need an update because of some zero-day CVE.&lt;/p&gt;

&lt;p&gt;As a result, we end up with different states of the same product. Yesterday a feature did not exist, today it does, and tomorrow an old API is marked as deprecated. We need a way to name each of these states precisely.&lt;/p&gt;

&lt;p&gt;That is what versions are for.&lt;/p&gt;

&lt;p&gt;But this is where things get interesting. There are actually a great many questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What is a version, anyway? Is it a number, a string, or a sequence of characters?&lt;/li&gt;
&lt;li&gt;When should you release a new version?&lt;/li&gt;
&lt;li&gt;If the software consists of several independent parts, how do the versions of those parts relate to one another?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In fact, the requirements for a version depend less on the programming language or build system than on &lt;strong&gt;how the product reaches the user and who depends on it&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The version of a desktop application, the version of a Java library, and the version of a twenty-component APM platform like Dynatrace solve three different problems. You can try to apply one scheme to all of them mechanically. Is that a good idea? Let us find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Relatively Simple Case: A Desktop Application
&lt;/h2&gt;

&lt;p&gt;Imagine a regular desktop application for an end user. Not an IDE with a vast plugin ecosystem, not an operating system, and not Excel, around which a company has built half of its processes. Just an application.&lt;/p&gt;

&lt;p&gt;Such a product, &lt;em&gt;as a rule&lt;/em&gt;, has two important characteristics:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The primary consumption model is that the user interacts with the product rather than compiling their own code against its API.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In that case, a backward-compatibility break often looks like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Yesterday I saw this button on this page, but today it is gone—it is on another page&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In other words, &lt;strong&gt;the contract is often not between programs, but between the UI and the user's eyes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. There is no large dependency graph of other libraries between the application and the user.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Again, desktop applications vary. Some have many integrations with external systems—Excel, for example—but &lt;em&gt;"many"&lt;/em&gt; is a relative term, and every product is unique here.&lt;/p&gt;

&lt;h3&gt;
  
  
  CalVer. Common Case
&lt;/h3&gt;

&lt;p&gt;In the general case, &lt;a href="https://calver.org/" rel="noopener noreferrer"&gt;Calendar Versioning&lt;/a&gt;, or CalVer, works quite well. It is a version format in which the release date is encoded into the version in one way or another. For example, the CalVer version &lt;code&gt;2026.1&lt;/code&gt; might mean the first release of 2026. JetBrains IDEs use a similar scheme.&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%2Fxfi4pgpysnebv9ob9vbj.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%2Fxfi4pgpysnebv9ob9vbj.png" alt="CalVer" width="800" height="745"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One well-known example of software that follows CalVer is Canonical's Ubuntu. Ubuntu is released twice a year, in April and October, so its versions follow this format: &lt;code&gt;22.04&lt;/code&gt; is the April release, while &lt;code&gt;22.10&lt;/code&gt; is the October release.&lt;/p&gt;

&lt;p&gt;This is convenient for end users. The version number immediately answers a question they understand: &lt;strong&gt;how recent is the release in front of me, and when did it come out?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Note that CalVer does not say that a product is stable or backward-compatible with anything. It communicates the release date. For consumer software, that is often enough and generally a good choice, because users simply do not have to assess the compatibility of individual libraries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Marketing Version
&lt;/h3&gt;

&lt;p&gt;But there is a separate class of desktop software that releases relatively infrequently and treats each release as a major event. In those situations, &lt;strong&gt;what matters is not so much when the product was released, but the very fact that a release happened at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For example, Microsoft quite successfully released Windows 7, 8, 10, and 11. This approach is often called &lt;a href="https://wiki.apidesign.org/wiki/MarketingVersion" rel="noopener noreferrer"&gt;Marketing Versioning&lt;/a&gt;, because its primary purpose is marketing—the ability to announce loudly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Yesterday you had Windows 10, and now, after several years of development, Windows 11 is coming out!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That release will be accompanied by an entire marketing campaign. It is not just another Ubuntu release.&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%2F2i2ms602bsiqvr30s4jt.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%2F2i2ms602bsiqvr30s4jt.png" alt="Windows 10" width="575" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For end users, it is easier to look at 10 and understand that it is less than 11 than it is to compare Ubuntu 22.04 with 24.10. Of course, Marketing Versioning is a very niche approach for major, foundational software products, but it is justified because, from a marketing perspective, people find a single number easier to understand and compare.&lt;/p&gt;

&lt;p&gt;Again, this does not mean that every desktop application must use CalVer or Marketing Versioning. My point is simpler: if the version number's main value to the user is distinguishing a recent release from an old one, and releases arrive relatively regularly, a calendar-based scheme solves the problem honestly. If you plan to release very rarely and treat each release as an event in its own right, Marketing Versioning may be worth considering.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Others Begin to Depend on You
&lt;/h2&gt;

&lt;p&gt;Let us move on and change the situation slightly. We are developing a library rather than an application.&lt;/p&gt;

&lt;p&gt;The situation is fundamentally different here. The user does not merely run our product. They compile their code against it and use our classes, methods, annotations, and configuration properties. Our library becomes part of their program.&lt;/p&gt;

&lt;p&gt;Now the user is interested in more than how recent the release is. Before upgrading, they want to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether compilation will break;&lt;/li&gt;
&lt;li&gt;whether runtime behavior will change;&lt;/li&gt;
&lt;li&gt;whether the configuration must be rewritten;&lt;/li&gt;
&lt;li&gt;whether a patch can be accepted automatically through Dependabot;&lt;/li&gt;
&lt;li&gt;how much time to budget for migration.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Semantic Versioning. The Cornerstone
&lt;/h3&gt;

&lt;p&gt;This is where the familiar &lt;a href="https://semver.org/" rel="noopener noreferrer"&gt;Semantic Versioning&lt;/a&gt;, or SemVer, enters the picture. Many projects follow its format:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spring Boot&lt;/li&gt;
&lt;li&gt;Quarkus&lt;/li&gt;
&lt;li&gt;Jackson, and so on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Axelix follows it as well, but more on that later. In its classic form, a version has the format &lt;code&gt;MAJOR.MINOR.PATCH&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;MAJOR&lt;/code&gt; increases when a backward-incompatible change is made to the public API;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MINOR&lt;/code&gt; increases when backward-compatible functionality is added;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PATCH&lt;/code&gt; increases when a backward-compatible fix is made.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A version may also have a qualifier, though this is optional. Here are a few widely known examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Alpha&lt;/code&gt;/&lt;code&gt;Beta&lt;/code&gt; versions, such as &lt;code&gt;8.0.0.Alpha1&lt;/code&gt;. Hibernate &lt;a href="https://mvnrepository.com/artifact/org.hibernate.orm/hibernate-core" rel="noopener noreferrer"&gt;adds qualifiers like these&lt;/a&gt; for alpha and beta testing. We will not go into that now.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Milestone&lt;/code&gt; versions, such as &lt;code&gt;4.0.0-M1&lt;/code&gt;. We will not explore the purpose of milestone releases either, but I do want to note that large projects such as Spring Boot and Axelix sometimes use them to gather early feedback.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;For example, moving from &lt;code&gt;2.4.1&lt;/code&gt; to &lt;code&gt;2.4.2&lt;/code&gt; should be boring. Update the dependency, run the tests, and move on. But moving from &lt;code&gt;2.4.2&lt;/code&gt; to &lt;code&gt;3.0.0&lt;/code&gt; warns you in advance: folks, some work may be required here.&lt;/p&gt;

&lt;p&gt;That is the key value of SemVer. The version number becomes a cheap protocol between the maintainer and the user. You do not need to read the entire changelog just to estimate the scale of a migration. &lt;strong&gt;The number itself provides an initial estimate of the migration's complexity&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  SemVer Requires a Public API
&lt;/h3&gt;

&lt;p&gt;There is a nuance that people sometimes forget.&lt;/p&gt;

&lt;p&gt;If you actually read the specification, SemVer is built around what it calls a "public API." The specification says so explicitly. This is logical: otherwise, it would be impossible to determine whether a change is backward-incompatible. And here is my question for you.&lt;/p&gt;

&lt;p&gt;What counts as the public API of a Spring Boot starter?&lt;/p&gt;

&lt;p&gt;Only its Java classes? What about configuration properties? Are bean names part of the contract? What about the format of an actuator endpoint? The order in which auto-configurations are discovered? The proxying strategies used by auto-configuration? As the saying goes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;It all looked smooth on paper, but they forgot about the ravines&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On paper, the rule "break the API, increase MAJOR" looks simple. But in a real framework, &lt;strong&gt;the compatibility surface is enormous&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Sometimes even a critical bug fix in a patch release changes behavior that someone has already come to rely on. Sometimes a CVE cannot be fixed without tightening old rules. Sometimes a major upgrade to a transitive dependency breaks things for the user even though your own Java API has not changed at all.&lt;/p&gt;

&lt;p&gt;Experienced engineers need to remember that following pure SemVer is extremely difficult in practice. You are unlikely to sustain it over the long term in a complex product.&lt;/p&gt;

&lt;p&gt;It is therefore important to understand that &lt;strong&gt;a three-number product version does not necessarily mean that the project follows strict SemVer&lt;/strong&gt;. And rather than make an unsupported claim, let us look at an example. For many people, it may be an eye-opener.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring Boot and the Pain Index
&lt;/h2&gt;

&lt;p&gt;Spring Boot is a very good example.&lt;/p&gt;

&lt;p&gt;Spring Boot uses the familiar &lt;code&gt;MAJOR.MINOR.PATCH&lt;/code&gt; format, but &lt;a href="https://github.com/spring-projects/spring-boot/wiki/Team-Practices" rel="noopener noreferrer"&gt;the project team states explicitly&lt;/a&gt; that strict Semantic Versioning is practically impossible for Boot. Otherwise, the major version would have to increase far too often.&lt;/p&gt;

&lt;p&gt;Instead, the version number is used as a kind of &lt;strong&gt;expected pain index&lt;/strong&gt; for an upgrade:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a patch should be as close as possible to a drop-in replacement;&lt;/li&gt;
&lt;li&gt;a minor release introduces new capabilities and usually requires little upgrade effort;&lt;/li&gt;
&lt;li&gt;a major release is reserved for significant breaking changes where migration work is expected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This resembles SemVer, but it is not strict SemVer. &lt;strong&gt;A Spring Boot minor release may contain incompatible changes&lt;/strong&gt;. Moreover, Boot manages the versions of a large number of third-party libraries, and upgrading Boot itself updates that entire set.&lt;/p&gt;

&lt;p&gt;My advice is this: the scheme above is viable, and if you are considering SemVer, take it seriously. On the whole, it fulfills the role SemVer was designed to fulfill: &lt;strong&gt;letting users understand the scale of the changes relatively quickly—and roughly how painful the move will be—without diving into release notes, rebuilding things, and so on&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Strict SemVer simply prescribes: Major == backward-incompatible changes. In practice, the formula looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Patch:&lt;/strong&gt; upgrade; everything will be fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minor:&lt;/strong&gt; you may have to put in a little effort, but it will probably be no big deal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Major:&lt;/strong&gt; you will definitely need to spend time on migration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Versioning exists for users, not users for versioning. If a strict scheme forces a mature framework to release &lt;code&gt;Spring Boot 47&lt;/code&gt;, then perhaps that scheme does not describe the reality of that particular project very well.&lt;/p&gt;

&lt;p&gt;What matters is that the promise be stated explicitly. The user should understand that &lt;code&gt;3.4&lt;/code&gt; -&amp;gt; &lt;code&gt;3.5&lt;/code&gt; means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"&lt;em&gt;Most likely&lt;/em&gt;, the upgrade will be relatively straightforward."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It does not mean a mathematical guarantee that there are no incompatible changes.&lt;/p&gt;

&lt;p&gt;So, friends, do not confuse SemVer's syntax with its semantics. You can put &lt;code&gt;1.2.3&lt;/code&gt; on anything. It becomes valuable only when a project has explained the rules and genuinely tries to follow them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What If the Product Has Multiple Components?
&lt;/h2&gt;

&lt;p&gt;Now let us add even more interesting details to the equation. A modern software platform rarely consists of a single artifact.&lt;/p&gt;

&lt;p&gt;Take any APM solution, such as Dynatrace or Datadog. They have a server and separate agents that collect information—different components. Take Kubernetes: it has a kubelet on every node, a Control Plane, and so on. &lt;strong&gt;These are all different parts of a single product&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At Axelix, for example, we have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;build plugins for Maven and Gradle;&lt;/li&gt;
&lt;li&gt;a Spring Boot starter;&lt;/li&gt;
&lt;li&gt;Axelix Master as a standalone executable JAR;&lt;/li&gt;
&lt;li&gt;a Docker image for Master;&lt;/li&gt;
&lt;li&gt;a Helm chart.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Importantly, all these "components" are generally distributed separately and may be owned by different teams. In our case, for example, the starter and plugins arrive in a service from Maven Central. They are simply Maven artifacts maintained by &lt;strong&gt;a separate product team&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Axelix Master, meanwhile, is deployed by the platform/ops team. The Docker image and Helm chart even live in their own distribution channels.&lt;/p&gt;

&lt;p&gt;This architecture means that, technically, each component &lt;em&gt;can&lt;/em&gt; have an independent version and change at its own pace. For example, nothing may have changed in the starter while we fixed the UI in Master. Or, conversely, we may need to release a patch for a build plugin without touching the server.&lt;/p&gt;

&lt;p&gt;This leaves us with a choice between two strategies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Independent Versioning
&lt;/h3&gt;

&lt;p&gt;Each component gets its own version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Master &lt;code&gt;3.7.1&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;starter &lt;code&gt;2.4.0&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;Maven plugin &lt;code&gt;5.1.3&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;Helm chart &lt;code&gt;1.9.2&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From our perspective as maintainers, this is a clean and logical model. When one component changes, we increase only its version according to that &lt;em&gt;"Pain Index"&lt;/em&gt;. There is no need to publish artifacts whose code has not changed. It makes life easier for us maintainers.&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%2Fr8wbc2byfb0km61mwgrj.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%2Fr8wbc2byfb0km61mwgrj.png" alt="Various Versions" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But now the complexity has shifted to the user.&lt;/p&gt;

&lt;p&gt;Is starter &lt;code&gt;2.4&lt;/code&gt; compatible with Master &lt;code&gt;3.7&lt;/code&gt;? Which plugin does starter &lt;code&gt;2.4&lt;/code&gt; need? Can chart &lt;code&gt;1.9&lt;/code&gt; be used with image &lt;code&gt;3.7.1&lt;/code&gt;? The result is a &lt;em&gt;"compatibility matrix"&lt;/em&gt; alongside the product—one that must be documented, tested, and continuously maintained.&lt;/p&gt;

&lt;p&gt;A very good example is the long-running saga between Spring Boot and Spring Cloud. Because they are technically separate projects with their own versioning strategies, they have a &lt;a href="https://spring.io/projects/spring-cloud" rel="noopener noreferrer"&gt;Compatibility Matrix&lt;/a&gt; that tells you whether a particular Spring Cloud version can be used with a particular Spring Boot version.&lt;/p&gt;

&lt;p&gt;And here is the question: &lt;strong&gt;is having a Compatibility Matrix inherently bad?&lt;/strong&gt; You are software users yourselves. Ask yourself: how enjoyable is it to figure out every time whether this version of component A is compatible with that version of component B? It is hardly the most pleasant way to spend your time.&lt;/p&gt;

&lt;p&gt;Independent versions make life easier for the release team, but increase the number of states the user sees. Users already have plenty to worry about, and the last thing they want is to search for which version of component A is compatible with a particular version of component B.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lockstep Versioning
&lt;/h3&gt;

&lt;p&gt;The second strategy is called lockstep versioning. All components of a product are released under the same version.&lt;/p&gt;

&lt;p&gt;If we release Axelix &lt;code&gt;1.4.2&lt;/code&gt;, then Master, the starter, plugins, image, and chart all receive version &lt;code&gt;1.4.2&lt;/code&gt;. This strategy is used by modules within projects such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spring Boot (actuator, autoconfigure, plugins, and so on)&lt;/li&gt;
&lt;li&gt;React's core modules (react, react-dom, and so on)&lt;/li&gt;
&lt;li&gt;Quarkus's core modules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a very important distinction here. SemVer and CalVer answer the question, &lt;strong&gt;how is the version number structured?&lt;/strong&gt; Lockstep answers a different question: &lt;strong&gt;which components share that number?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So you can absolutely use SemVer together with lockstep. Or CalVer together with lockstep. These are not competing concepts. The advantages of lockstep are clear:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The user deals with a single product version.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is extremely convenient. You need to know only &lt;strong&gt;one&lt;/strong&gt; version. When you ask a colleague:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Which Spring Boot version does your microservice use?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You have to remember that Spring Boot is an umbrella for many modules, yet everyone answers &lt;code&gt;2.7&lt;/code&gt; or &lt;code&gt;3.5&lt;/code&gt;: people deal with a single version. &lt;strong&gt;It is just ridiculously convenient&lt;/strong&gt;. Likewise, the question "Which version of Axelix do you use?" can be answered with &lt;code&gt;1.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The compatible combination of components is obvious.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Users understand that with Axelix Master version &lt;code&gt;1.1.0&lt;/code&gt;, they can use Spring Boot Starter version &lt;code&gt;1.1.0&lt;/code&gt; (in reality, it is a little more complicated; more on that below).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Documentation, examples, and release notes are tied to a single number.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For platform products, these are very strong advantages. But there is no free lunch, of course: lockstep has significant problems of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost of Lockstep
&lt;/h2&gt;

&lt;p&gt;Experienced engineers know that lockstep is a rather controversial versioning strategy for related components. It has two classic problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A backward-incompatible change in one component formally affects the version of the entire product.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In other words, as soon as you realize that component A needs a major version bump, another realization follows immediately: &lt;strong&gt;the entire product needs a major version bump&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Suppose we break only the public API of the Maven plugin. If the project follows strict SemVer and all components move in lockstep, Master, the starter, the Docker image, and the Helm chart all need a major version bump even though none of them broke.&lt;/p&gt;

&lt;p&gt;This problem can be partially mitigated by following not SemVer in its strict sense, but specifically the Spring Boot team's interpretation of SemVer that I described above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Patching one component results in a patch release of the entire set.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Suppose a CVE exists only in a web dependency of Master. The starter does not have that dependency and has not changed at all. But in the lockstep model, the new product release will still be, for example, &lt;code&gt;1.4.3&lt;/code&gt; for every artifact.&lt;/p&gt;

&lt;p&gt;This is convenient for users. For release engineering, it means additional work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;build all components;&lt;/li&gt;
&lt;li&gt;run the shared pipeline;&lt;/li&gt;
&lt;li&gt;publish several types of artifacts;&lt;/li&gt;
&lt;li&gt;update the documentation in sync;&lt;/li&gt;
&lt;li&gt;make sure no registry remains on the old version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my experience, this too can be mitigated to a large extent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lockstep with Monorepos
&lt;/h3&gt;

&lt;p&gt;Lockstep works well &lt;strong&gt;for monorepositories that use Trunk Based Development or GitHub Flow&lt;/strong&gt;. That is why lockstep was such a good fit for Spring Boot—a monorepo using GitHub Flow—and for Axelix—a monorepo using a slightly modified form of TBD, &lt;a href="https://habr.com/ru/companies/spring_aio/articles/1072386/" rel="noopener noreferrer"&gt;which I covered in a separate article&lt;/a&gt;. If a project is organized as a monorepo using TBD, lockstep often becomes not merely simpler, but the natural strategy.&lt;/p&gt;

&lt;p&gt;That is why, as my grandfather used to say, humanity's three most important discoveries are fire, the wheel, and the monorepository.&lt;/p&gt;

&lt;p&gt;There is one more problem that has no simple solution: &lt;strong&gt;communication&lt;/strong&gt;. In practice, lockstep requires very smooth coordination among the teams developing the different components of your platform, because the entire platform is released under one version.&lt;/p&gt;

&lt;p&gt;But let me remind you that &lt;strong&gt;Open Source is developed by small teams&lt;/strong&gt;. The core teams behind Open Source solutions are small!&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Axelix: a core team of four people and dozens of external contributors&lt;/li&gt;
&lt;li&gt;Spring Boot: a core team of, &lt;strong&gt;at the moment&lt;/strong&gt;, roughly 5–7 people, plus dozens of external contributors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And because the entire platform is generally developed by one small team, lockstep does not create much communication overhead for that team when coordinating changes, releases, and so on.&lt;/p&gt;

&lt;p&gt;So if your team is small, &lt;strong&gt;you probably will not notice lockstep's communication overhead&lt;/strong&gt;. If you are developing a platform product, user convenience is often more important than the elegance of internal release processes, especially when those processes can be simplified through the structure of the codebase.&lt;/p&gt;

&lt;p&gt;Nevertheless, that is still not the whole story...&lt;/p&gt;

&lt;h2&gt;
  
  
  A Lockstep Release Does Not Mean a Lockstep Upgrade
&lt;/h2&gt;

&lt;p&gt;This is where we reach the most interesting part.&lt;/p&gt;

&lt;p&gt;You can release all components under version &lt;code&gt;1.4&lt;/code&gt;, &lt;strong&gt;but you cannot force an organization to upgrade them simultaneously&lt;/strong&gt;. For example, Axelix Master is controlled by the platform team. It can upgrade a single deployment in one evening; &lt;a href="https://axelix.io/docs/ru/more/upgrading#%D0%BE%D0%B1%D0%BD%D0%BE%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5-%D1%81%D0%B0%D0%BC%D0%BE%D0%B3%D0%BE-axelix-master" rel="noopener noreferrer"&gt;the documentation describes a dedicated procedure for this&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The starter and build plugin, however, live &lt;strong&gt;in dozens or hundreds of services&lt;/strong&gt;. Every service has its own team, backlog, release window, and adventures.&lt;/p&gt;

&lt;p&gt;If moving Master from &lt;code&gt;1.3&lt;/code&gt; to &lt;code&gt;1.4&lt;/code&gt; requires a hundred services to be upgraded in the same minute, then the system formally uses lockstep versioning but, in practice, never gets upgraded at all. Such a system cannot realistically be upgraded—it is operationally impossible to make dozens of teams update the versions of various components simultaneously.&lt;/p&gt;

&lt;p&gt;In other words, as a Technical Leader or senior engineer, you need to think beyond how you will release versions, what those versions will look like, and how people will perceive them. &lt;strong&gt;You must think in advance about how people will upgrade your product in their environments&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let me share how we approached this in Axelix and how major projects handle it, using Kubernetes as an example.&lt;/p&gt;

&lt;h3&gt;
  
  
  Introducing Compatibility/Skew Window
&lt;/h3&gt;

&lt;p&gt;To solve this problem, you create what is known as a Compatibility Window. Do not confuse it with a Compatibility Matrix.&lt;/p&gt;

&lt;p&gt;Even the most nontrivial systems made up of multiple components have &lt;strong&gt;a clear upgrade order&lt;/strong&gt;: first component A is upgraded, then component B.&lt;/p&gt;

&lt;p&gt;The component upgraded first can often be upgraded &lt;em&gt;relatively atomically&lt;/em&gt;. In Axelix, for example, Master is always upgraded first. Updating it is fairly simple: you ask the platform team to perform the version upgrade in the evening.&lt;/p&gt;

&lt;p&gt;That first component—in our case, Master—must then guarantee a &lt;strong&gt;rolling compatibility window&lt;/strong&gt;, often phrased as follows:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Master supports starters from the latest &lt;code&gt;N&lt;/code&gt; releases (excluding patches), including its own release.&lt;/p&gt;
&lt;/blockquote&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%2F5a25sc1d4u3bx2tnj9ra.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%2F5a25sc1d4u3bx2tnj9ra.png" alt="Compatibility Window in Axelix Master" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For us, &lt;code&gt;N == 4&lt;/code&gt; at the moment. Here is what that looks like in a concrete example:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Starter version&lt;/th&gt;
&lt;th&gt;Supported by Master 1.4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1.4.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1.3.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1.2.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1.1.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1.0.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a compatibility window directed &lt;strong&gt;backward&lt;/strong&gt;: a new Master understands old starters. You cannot run a starter that is newer than Master.&lt;/p&gt;

&lt;h3&gt;
  
  
  Existing Example. K8S Skew Policy
&lt;/h3&gt;

&lt;p&gt;A similar principle appears in the official Kubernetes &lt;a href="https://kubernetes.io/releases/version-skew-policy/" rel="noopener noreferrer"&gt;Version Skew Policy&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kubelet&lt;/code&gt; cannot be newer than &lt;code&gt;kube-apiserver&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubelet&lt;/code&gt; can be up to three minor versions older than &lt;code&gt;kube-apiserver&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kube-apiserver&lt;/code&gt; is upgraded first, and only then can &lt;code&gt;kubelet&lt;/code&gt; be upgraded gradually on individual nodes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, &lt;code&gt;kube-apiserver&lt;/code&gt; version &lt;code&gt;1.37&lt;/code&gt; supports &lt;code&gt;kubelet&lt;/code&gt; versions &lt;code&gt;1.37&lt;/code&gt;, &lt;code&gt;1.36&lt;/code&gt;, &lt;code&gt;1.35&lt;/code&gt;, and &lt;code&gt;1.34&lt;/code&gt;. The logic is the same: first upgrade the centralized component atomically, then gradually bring the distributed components up to date.&lt;/p&gt;

&lt;p&gt;In practice, the "Skew Policy" is exactly that rolling window. It is fundamentally the same thing, simply expressed in different words.&lt;/p&gt;

&lt;p&gt;I hope the general logic is clear. In that case, the upgrade procedure looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The platform team checks the oldest starter still present in the fleet.&lt;/li&gt;
&lt;li&gt;It upgrades Master first.&lt;/li&gt;
&lt;li&gt;Service teams independently upgrade the starter and build plugin.&lt;/li&gt;
&lt;li&gt;Gradually, the entire fleet converges on the new version.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Compatibility Window. Trade-Off
&lt;/h3&gt;

&lt;p&gt;Naturally, &lt;strong&gt;this is a trade-off&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The larger &lt;code&gt;N&lt;/code&gt; is, the more time users have to upgrade, but the harder your job becomes: you have to support a long tail of old APIs, and remember that all of this must also be tested in matrix builds.&lt;/p&gt;

&lt;p&gt;The smaller &lt;code&gt;N&lt;/code&gt; is, the less time users have to upgrade and the less comfortable it is for them, but the easier your job becomes: you can aggressively drop old APIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important advice:&lt;/strong&gt; if you are at the beginning of building a product, prioritize the ability to make aggressive upgrades while the product has not yet achieved widespread adoption or accumulated a huge number of users. &lt;strong&gt;Over time, move &lt;code&gt;N&lt;/code&gt; upward&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Scheme Should You Choose?
&lt;/h2&gt;

&lt;p&gt;There are many more things I have not covered. Perhaps they deserve a separate article. But I think I have already thrown a great deal of information at you, and it is better to stop here before it all turns into a jumble.&lt;/p&gt;

&lt;p&gt;Let us summarize the practical takeaways.&lt;/p&gt;

&lt;h3&gt;
  
  
  If You Have a Consumer Application
&lt;/h3&gt;

&lt;p&gt;Ask yourself two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How often do you release? Is each release a major event for you, or is it a more-or-less routine process?&lt;/li&gt;
&lt;li&gt;How many external integration points do you have?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the general case, CalVer or Marketing Versioning will most often be the right choice for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  If You Have a Library
&lt;/h3&gt;

&lt;p&gt;SemVer is a good starting point. But first, define the public API explicitly. Without that, the promise of backward compatibility will be extremely vague.&lt;/p&gt;

&lt;p&gt;In practice, you will probably come to realize over time that the public API surface is too large. In that case, look at Spring Boot's model and the &lt;em&gt;"Pain Index"&lt;/em&gt;, and &lt;strong&gt;describe your approach explicitly&lt;/strong&gt;. The worst situation is when users believe they are getting a guarantee that does not actually exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  If You Have Several Closely Related Components
&lt;/h3&gt;

&lt;p&gt;Consider lockstep. It is especially useful if users perceive the product as a single whole and there are not many compatible combinations. Lockstep has procedural and communication problems of its own, but there are ways to ease those pains too.&lt;/p&gt;

&lt;p&gt;If you choose lockstep, &lt;strong&gt;invest in release automation immediately&lt;/strong&gt;, because even a small patch to one component becomes a release of the entire set. Ideally, you will need a monorepo.&lt;/p&gt;

&lt;h3&gt;
  
  
  If You Have an Ecosystem of Independent Projects
&lt;/h3&gt;

&lt;p&gt;You will inevitably need to establish a rolling compatibility window across releases, because a large organization will not be able to upgrade your solution atomically. A situation is guaranteed to arise in which an older version of component A must work with a newer version of component B.&lt;/p&gt;

&lt;p&gt;The size of the compatibility window is a trade-off between your development speed and user convenience. That trade-off should be different at different stages of the product's evolution.&lt;/p&gt;

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

&lt;p&gt;A version is not merely a release counter. It is part of a product's public contract.&lt;/p&gt;

&lt;p&gt;A good version should help the user answer at least one important question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how recent this release is;&lt;/li&gt;
&lt;li&gt;how painful the upgrade will be;&lt;/li&gt;
&lt;li&gt;whether the components are compatible;&lt;/li&gt;
&lt;li&gt;which set of dependencies has been tested together;&lt;/li&gt;
&lt;li&gt;how much time the team has left for migration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CalVer answers the question of time. SemVer tries to describe API compatibility. The "Pain Index" estimates upgrade complexity. Lockstep reduces the number of product versions.&lt;/p&gt;

&lt;p&gt;There is no need to choose the trendiest scheme. You need to choose a promise that matches the product's architecture and that your team is genuinely capable of keeping.&lt;/p&gt;

&lt;p&gt;Because the number &lt;code&gt;2.4.1&lt;/code&gt; guarantees nothing by itself. The guarantee comes only from the discipline of a team that has explained what those numbers mean and followed its own rules for years.&lt;/p&gt;

&lt;p&gt;Best of luck, everyone!&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>software</category>
      <category>designsystem</category>
    </item>
    <item>
      <title>New Java Features Are Not Just for Interviews. Two Real-World Use Cases from Axelix</title>
      <dc:creator>Sergei Cherkasov</dc:creator>
      <pubDate>Wed, 16 Sep 2026 14:40:54 +0000</pubDate>
      <link>https://dev.to/axelix-labs/new-java-features-are-not-just-for-interviews-two-real-world-use-cases-from-axelix-51aa</link>
      <guid>https://dev.to/axelix-labs/new-java-features-are-not-just-for-interviews-two-real-world-use-cases-from-axelix-51aa</guid>
      <description>&lt;p&gt;Hey everyone! Mikhail Polivakha here, technical lead of the &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;Axelix project&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Java moved to a six-month release cycle quite a while ago. Every six months, we get a new version, read about the latest JEPs, and watch conference talks. Then we go back to work and continue writing roughly the same code we were writing five years ago.&lt;/p&gt;

&lt;p&gt;And that is not necessarily a bad thing, by the way. Bringing a new feature into production merely because it is new is not exactly the most brilliant engineering strategy. Moreover, many applications are still running on Java 17, while Java 11 is alive and well in some places.&lt;/p&gt;

&lt;p&gt;But this raises a natural question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do new Java features have a place in ordinary, real-world applications? Not in a presentation, not in a toy pet project, but in code that solves an actual problem?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Yes, they do.&lt;/p&gt;

&lt;p&gt;In this article, I will examine two examples from the open-source Axelix codebase:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How we use &lt;code&gt;ScopedValue&lt;/code&gt; to pass a security context from an HTTP filter deep into the transport layer.&lt;/li&gt;
&lt;li&gt;Why one of our interfaces became &lt;code&gt;sealed&lt;/code&gt;, and not for pattern matching or an exhaustive &lt;code&gt;switch&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At first glance, these examples seem completely unrelated. One is about execution context, while the other is about the overall domain design. But they actually share one common idea: &lt;strong&gt;a good language feature lets us turn an agreement between developers into a constraint enforced by the platform itself&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Well then, let's get started.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Little Context About Axelix
&lt;/h2&gt;

&lt;p&gt;So that the code below does not look like a collection of random classes, let me first say a few words about the architecture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;Axelix&lt;/a&gt; is the Open Source product that helps to debug problems in Spring Boot applications and also detects common issues and inefficiencies in them. In simplified terms, the system consists of two parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Axelix Master aggregates information, handles the RBAC and provides a UI and a built-in MCP server.&lt;/li&gt;
&lt;li&gt;Spring Boot starters, together with a small Build Plugin, are installed in the monitored applications and provide Master with the necessary data and operations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a user invokes an operation through the UI or an AI Agent, Master sometimes has to contact the starter of a particular application. For example, it may request the environment state, retrieve information about beans, or clear a cache.&lt;/p&gt;

&lt;p&gt;And this is where a security-related requirement comes into play: &lt;strong&gt;Master has to pass the starter the authorization token associated with the security context in which the operation is being executed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The token appears fairly high up, inside a servlet filter. It is needed much further down, in the transport layer where the outgoing HTTP request is built.&lt;/p&gt;

&lt;p&gt;Of course, we could add a &lt;code&gt;SecurityContext&lt;/code&gt; parameter to every method between these two points. Then to five more methods. Then to ten. And six months later, we would discover that half of the application is busy with the fascinating task of moving a token from one argument to another.&lt;/p&gt;

&lt;p&gt;Although that works too, we wanted to find a better solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Case One. Passing Context Through &lt;code&gt;ThreadLocal&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Historically, the standard solution to this kind of problem in Java has been &lt;code&gt;ThreadLocal&lt;/code&gt;. Spring Security, for example, used to work this way.&lt;/p&gt;

&lt;p&gt;The idea is very simple:&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;ThreadLocal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SecurityContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&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;ThreadLocal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;runWithinContext&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="nc"&gt;SecurityContext&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="nc"&gt;SecurityContext&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;CONTEXT&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="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="no"&gt;CONTEXT&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;context&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="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="o"&gt;{&lt;/span&gt;
            &lt;span class="no"&gt;CONTEXT&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="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="no"&gt;CONTEXT&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code running further down the call stack in the same thread can call &lt;code&gt;CONTEXT.get()&lt;/code&gt; and obtain the required value. There is no need to pass the parameter through every intermediate method.&lt;/p&gt;

&lt;p&gt;So where is the problem?&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ThreadLocal&lt;/code&gt; Gives Us More Capabilities Than We Need
&lt;/h3&gt;

&lt;p&gt;The first and fairly well-known problem with &lt;code&gt;ThreadLocal&lt;/code&gt; is that any code with a reference to it can call not only &lt;code&gt;get()&lt;/code&gt;, but also &lt;code&gt;set()&lt;/code&gt; or &lt;code&gt;remove()&lt;/code&gt;. In other words, a &lt;code&gt;ThreadLocal&lt;/code&gt; is essentially shared mutable state, and &lt;strong&gt;experienced engineers know that this is a torpedo lodged in the side of our "ship"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The calling code stores the context of the authenticated user, but some distant callee can theoretically replace that context. Some tasks genuinely require this kind of mutability. In our case, however, the data flows strictly in one direction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The filter authenticates the user.&lt;/li&gt;
&lt;li&gt;The filter creates a &lt;code&gt;SecurityContext&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The code further down only reads that context.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing further down the stack should change the user's identity. We do not merely have no need for this capability, but &lt;strong&gt;it effectively represents a security breach!&lt;/strong&gt; Suppose, for example, that some third-party implementation of an SPI interface can mess with the &lt;code&gt;SecurityContext&lt;/code&gt;. That would be a serious problem.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;ThreadLocal&lt;/code&gt; has another issue. The lifetime of a value in a &lt;code&gt;ThreadLocal&lt;/code&gt; is not constrained by the structure of the code. We are responsible for calling &lt;code&gt;remove()&lt;/code&gt;, usually in a &lt;code&gt;finally&lt;/code&gt; block. Why is that a problem?&lt;/p&gt;

&lt;p&gt;Imagine that, for whatever reason, and the exact reason does not matter, we fail to clear the user's identity from the &lt;code&gt;ThreadLocal&lt;/code&gt; after processing an HTTP request. We then receive another request that happens to run on the same thread from the pool. For now, let's leave virtual threads and the thread-per-task execution model aside, since that is a separate topic altogether. The security context from one request may now leak into another request.&lt;/p&gt;

&lt;p&gt;Now imagine that the &lt;code&gt;SecurityContext&lt;/code&gt; left in the pool belonged to a user with the &lt;code&gt;ADMIN&lt;/code&gt; role, which allows them not only to read application properties but also to view sensitive values. By default, Axelix has its own policy for handling sensitive values. These values are configured through separate properties, and only an &lt;code&gt;ADMIN&lt;/code&gt; is allowed to read them. By leaving a user with the &lt;code&gt;ADMIN&lt;/code&gt; role in our &lt;code&gt;SecurityContext&lt;/code&gt;, we may effectively allow someone without that role to see secrets through either the UI or the MCP server.&lt;/p&gt;

&lt;p&gt;Of course, a careful developer will write a &lt;code&gt;try/finally&lt;/code&gt;. They will cover the code with tests. They will leave a comment. But if the correctness of the solution depends entirely on every future developer remembering an important comment, that is far from the strongest guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;ScopedValue&lt;/code&gt;. Not Storage, but a Bounded Execution Scope
&lt;/h2&gt;

&lt;p&gt;In Java 25, &lt;code&gt;ScopedValue&lt;/code&gt; was finalized. It solves a narrower problem than &lt;code&gt;ThreadLocal&lt;/code&gt;: it allows a value to be passed down the call chain for the duration of a particular operation.&lt;/p&gt;

&lt;p&gt;The key difference lies in the model itself. A &lt;code&gt;ThreadLocal&lt;/code&gt; resembles a mutable box attached to a thread. A &lt;code&gt;ScopedValue&lt;/code&gt; describes the binding of a value within a dynamic execution scope.&lt;/p&gt;

&lt;p&gt;This is how we use it to pass the &lt;a href="https://github.com/axelixlabs/axelix/blob/master/master/src/main/java/com/axelixlabs/axelix/master/service/auth/ScopedValueSecurityContextExecutor.java" rel="noopener noreferrer"&gt;&lt;code&gt;SecurityContext&lt;/code&gt; in Axelix Master&lt;/a&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;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;ScopedValue&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SecurityContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;SECURITY_CONTEXT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;ScopedValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newInstance&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;V&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;V&lt;/span&gt; &lt;span class="nf"&gt;callWithinSecurityContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;ThrowingCallable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;V&lt;/span&gt;&lt;span class="o"&gt;,&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;callable&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;SecurityContext&lt;/span&gt; &lt;span class="n"&gt;securityContext&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="no"&gt;T&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;ScopedValue&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="no"&gt;SECURITY_CONTEXT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;securityContext&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;callable:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;call&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 &lt;code&gt;where(...).call(...)&lt;/code&gt; invocation means approximately the following:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Execute this callback so that, within its dynamic scope, this &lt;code&gt;ScopedValue&lt;/code&gt; is bound to this &lt;code&gt;SecurityContext&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When the callback finishes, the binding disappears automatically. It does not matter whether execution completes normally or throws an exception, because &lt;code&gt;ScopedValue&lt;/code&gt; is designed to handle that case as well.&lt;/p&gt;

&lt;p&gt;Reading the context looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SecurityContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getSecurityContext&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="no"&gt;SECURITY_CONTEXT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBound&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="no"&gt;SECURITY_CONTEXT&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;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;empty&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;Notice that &lt;code&gt;ScopedValue&lt;/code&gt; itself has no &lt;code&gt;set&lt;/code&gt; method. Code further down can read the binding, but it cannot simply replace its value. If the same &lt;code&gt;ScopedValue&lt;/code&gt; needs to be temporarily bound to a different value, a new nested scope is created. Once that scope ends, the previous binding becomes visible again.&lt;/p&gt;

&lt;p&gt;In other words, the semantics we need are already expressed in the API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the value moves down the call stack;&lt;/li&gt;
&lt;li&gt;the binding exists only within a particular operation;&lt;/li&gt;
&lt;li&gt;a callee cannot mutate the binding;&lt;/li&gt;
&lt;li&gt;leaving the scope reliably restores the previous state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is exactly the kind of case where a new Java feature does not merely save a few lines involving &lt;code&gt;finally&lt;/code&gt;, but &lt;strong&gt;makes the correct model apparent from the code itself&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works in a Real Request
&lt;/h2&gt;

&lt;p&gt;Now let's put the whole flow together.&lt;/p&gt;

&lt;p&gt;For a regular UI request handled by &lt;a href="https://github.com/axelixlabs/axelix/blob/master/master/src/main/java/com/axelixlabs/axelix/master/filter/auth/ExternalApiCookieAuthorizationFilter.java" rel="noopener noreferrer"&gt;&lt;code&gt;ExternalApiCookieAuthorizationFilter&lt;/code&gt;&lt;/a&gt;, we obtain a JWT from a cookie, decode the user, and execute the remaining filter chain inside the security context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;securityContextExecutor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;runWithinSecurityContext&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;filterChain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DefaultSecurityContext&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;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an MCP invocation, the IAM flow is similar but slightly different. You can &lt;a href="https://github.com/axelixlabs/axelix/blob/master/master/src/main/java/com/axelixlabs/axelix/master/filter/auth/McpAuthorizationFilter.java" rel="noopener noreferrer"&gt;see the source code here&lt;/a&gt; if you are interested. I will not spend time on it now.&lt;/p&gt;

&lt;p&gt;The regular application logic then runs inside the filter chain. It may pass through controllers, services, and other abstractions. They do not need the token at all, so we do not pollute their APIs with it.&lt;/p&gt;

&lt;p&gt;The context is finally read in &lt;a href="https://github.com/axelixlabs/axelix/blob/master/master/src/main/java/com/axelixlabs/axelix/master/service/transport/AbstractEndpointProber.java" rel="noopener noreferrer"&gt;&lt;code&gt;AbstractEndpointProber&lt;/code&gt;&lt;/a&gt;, immediately before sending the request to the starter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SecurityContext&lt;/span&gt; &lt;span class="n"&gt;securityContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;securityContextExecutor&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSecurityContext&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"Security Context is expected to be bound"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AUTHORIZATION&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;AuthenticationSchemes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BEARER&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;securityContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;token&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;Once request processing finishes, the binding is removed, and the token can no longer be obtained through this &lt;code&gt;ScopedValue&lt;/code&gt;. Pretty neat!&lt;/p&gt;

&lt;h2&gt;
  
  
  Some Limitations of &lt;code&gt;ScopedValue&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I think it is only fair to mention a few limitations of this API. Overall, they are quite acceptable for us at the moment.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Binding Is Tied to a Thread
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ScopedValue&lt;/code&gt; is not automatically propagated to an arbitrary &lt;code&gt;CompletableFuture&lt;/code&gt;, executor task, or asynchronous servlet operation.&lt;/p&gt;

&lt;p&gt;The current Axelix code works because proxying the request to the starter, where the binding is read and the &lt;code&gt;Authorization&lt;/code&gt; header is set, happens synchronously inside the filter chain on the same thread. Child tasks can inherit a binding through &lt;code&gt;StructuredTaskScope&lt;/code&gt;, but in Java 25 that is still a Preview API.&lt;/p&gt;

&lt;p&gt;If you place a value in a &lt;code&gt;ScopedValue&lt;/code&gt; and then submit a task to some random thread pool, you cannot expect the value to be available there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Binding Is Immutable, but the Object Might Not Be
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ScopedValue&lt;/code&gt; does not allow the binding itself to be replaced. But if you put a mutable object inside it, its fields can still be changed using ordinary methods. This issue is not specific to &lt;code&gt;ScopedValue&lt;/code&gt;. It also applies to &lt;code&gt;ThreadLocal&lt;/code&gt; and a number of other APIs. Consider this example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ScopedValue&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;VALUE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ScopedValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newInstance&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binding of the list itself is stable here. Nevertheless, nothing prevents someone from calling &lt;code&gt;VALUE.get().add(...)&lt;/code&gt;, so the mutability question remains. Again, this problem is not unique to &lt;code&gt;ScopedValue&lt;/code&gt;, but it is worth keeping in mind.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Is Not a Universal Replacement for Parameters
&lt;/h3&gt;

&lt;p&gt;Generally speaking, a value can be passed to a method either explicitly, through its signature, or implicitly, through contexts of this kind. From a code maintainability perspective, if a value is an important part of a method's contract, an ordinary parameter is almost always better. In that case, &lt;strong&gt;the API contract is clearly visible in the signature&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You do not end up in a situation where the interface says this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;EndpointProber&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;O&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="no"&gt;O&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpPayload&lt;/span&gt; &lt;span class="n"&gt;payload&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;while the implementation is actually doing something like this under the hood:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DefaultEndpointProber&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;O&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;EndpointProber&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;O&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// Declared somewhere else, in another class&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;ScopedValue&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InstanceId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;TARGET_INSTANCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="nc"&gt;ScopedValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newInstance&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="no"&gt;O&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpPayload&lt;/span&gt; &lt;span class="n"&gt;payload&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;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;TARGET_INSTANCE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseGet&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instanceId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&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 code above is deceptive because the caller may think they have passed the token to use for the invocation, while in reality some other token source takes precedence. Again, this is not a problem specific to &lt;code&gt;ScopedValue&lt;/code&gt;. I simply thought it was worth highlighting. Experienced engineers most likely know this already.&lt;/p&gt;

&lt;p&gt;In general, &lt;code&gt;ScopedValue&lt;/code&gt; and &lt;code&gt;ThreadLocal&lt;/code&gt; are especially useful for context that is needed far down the call stack but is unrelated to most of the intermediate methods:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;security context;&lt;/li&gt;
&lt;li&gt;tracing context;&lt;/li&gt;
&lt;li&gt;tenant-id context;&lt;/li&gt;
&lt;li&gt;request metadata.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, the need for one-way context propagation must come first. Only then should &lt;code&gt;ScopedValue&lt;/code&gt; enter the picture.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Small Conclusion
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;ScopedValue&lt;/code&gt; when a value should only travel down the call chain and should live no longer than one bounded operation.&lt;/p&gt;

&lt;p&gt;If you need arbitrary mutation, integration with legacy code, or support for an older Java version, &lt;code&gt;ThreadLocal&lt;/code&gt; is not going anywhere. Just make sure to encapsulate the &lt;code&gt;set&lt;/code&gt; and &lt;code&gt;remove&lt;/code&gt; operations properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Case Two. What Do &lt;code&gt;sealed&lt;/code&gt; Interfaces Have to Do with It?
&lt;/h2&gt;

&lt;p&gt;Now let's move on to a completely different problem. Or, as will soon become clear, perhaps not entirely different.&lt;/p&gt;

&lt;p&gt;I want to warn you right away that &lt;strong&gt;the solution below is somewhat debatable, and I explain why later&lt;/strong&gt;. I am describing it largely to share our experience and hear what other people think in the comments. Nevertheless, this is exactly what we did.&lt;/p&gt;

&lt;p&gt;Axelix Master has a built-in MCP server. An AI Agent can invoke MCP tools, including both read operations and potentially dangerous operations, such as clearing a cache. As part of RBAC, we obviously need to check access rights for operations of this kind as well.&lt;/p&gt;

&lt;p&gt;Different MCP endpoints require different authorities. In simplified terms, Axelix Master has a structure like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;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;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;McpEndpoint&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Authority&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;MAPPING&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;MAPPING&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;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="no"&gt;MAPPING&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;McpEndpoints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CLEAR_ALL_CACHES&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OssAuthority&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CACHES_CLEAR&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="no"&gt;MAPPING&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;McpEndpoints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CLEAR_SPECIFIC_CACHE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OssAuthority&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CACHES_CLEAR&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
   &lt;span class="c1"&gt;// and so on.&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When we receive a JSON-RPC request to invoke an MCP tool, we parse the request and turn the string name of the MCP endpoint that the caller tried to invoke into an &lt;code&gt;McpEndpoint&lt;/code&gt; object. We then use that object as a &lt;code&gt;Map&lt;/code&gt; key and obtain the required authority.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Why use it as a key?&lt;/em&gt; Because it makes sense from the domain perspective: invoking a particular operation within the MCP server requires a particular authority. Having this kind of mapping seems to be quite right.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Could we simply use a &lt;code&gt;String&lt;/code&gt; as the key?&lt;/em&gt; Technically, yes, but that would introduce its own inconveniences given our implementation and class structure. I will not go into those details now.&lt;/p&gt;

&lt;p&gt;Suppose that &lt;code&gt;McpEndpoint&lt;/code&gt; is an interface. At this point, an experienced engineer reaches for the Colt on their belt!&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;McpEndpoint&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;name&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;It is just an interface. What could possibly go wrong?&lt;/p&gt;

&lt;h2&gt;
  
  
  A Potentially Mutable &lt;code&gt;HashMap&lt;/code&gt; Key. A Time Bomb
&lt;/h2&gt;

&lt;p&gt;Why did the experienced engineer reach for the Colt? Because, broadly speaking, &lt;code&gt;McpEndpoint&lt;/code&gt; is an interface, and we do not know what its implementations might look like. Keys in a &lt;code&gt;Map&lt;/code&gt; should be immutable, which is a &lt;a href="https://stackoverflow.com/questions/34060994/why-are-keys-immutable-in-java" rel="noopener noreferrer"&gt;widely known fact&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Imagine that someone implements the open interface like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MutableMcpEndpoint&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;McpEndpoint&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;MutableMcpEndpoint&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="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;name&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="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;name&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;name&lt;/span&gt;&lt;span class="o"&gt;;&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;rename&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="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;name&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="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;equals&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;other&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;other&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;MutableMcpEndpoint&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;
                &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;name&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="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;int&lt;/span&gt; &lt;span class="nf"&gt;hashCode&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;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hash&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="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let's put this endpoint into a &lt;code&gt;HashMap&lt;/code&gt; and then change its name:&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;endpoint&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;MutableMcpEndpoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"clearAllCaches"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mapping&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;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;McpEndpoint&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Authority&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OssAuthority&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CACHES_CLEAR&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rename&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"clearSomethingElse"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Authority&lt;/span&gt; &lt;span class="n"&gt;authority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mapping&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="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// surprise!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During &lt;code&gt;put&lt;/code&gt;, the &lt;code&gt;HashMap&lt;/code&gt; selected a bucket based on the old &lt;code&gt;hashCode&lt;/code&gt;. After &lt;code&gt;rename&lt;/code&gt;, the object returns a different &lt;code&gt;hashCode&lt;/code&gt;, but it is still physically located in the old bucket. A lookup may fail to find a key that is literally present inside the very same &lt;code&gt;Map&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;As a result, we could end up in a situation where we simply fail to detect that a particular &lt;code&gt;McpEndpoint&lt;/code&gt; requires an &lt;code&gt;Authority&lt;/code&gt; for its execution. &lt;strong&gt;That is yet another security breach!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Of course, we could write the following in the Javadoc:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All implementations of &lt;code&gt;McpEndpoint&lt;/code&gt; must be immutable and have stable &lt;code&gt;equals&lt;/code&gt; and &lt;code&gt;hashCode&lt;/code&gt; implementations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But once again, correctness would depend on a comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;sealed&lt;/code&gt; Is Not Just for &lt;code&gt;switch&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Sealed classes and interfaces were finalized as a language feature back in &lt;a href="https://openjdk.org/jeps/409" rel="noopener noreferrer"&gt;Java 17&lt;/a&gt;. They are usually explained using a closed hierarchy of shapes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Shape&lt;/span&gt; &lt;span class="n"&gt;permits&lt;/span&gt; &lt;span class="nc"&gt;Circle&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Rectangle&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 compiler then knows the complete set of alternatives and can verify exhaustive pattern matching. This is useful, but it is far from the only use case.&lt;/p&gt;

&lt;p&gt;If you think about it, the very nature of a sealed type as a language feature means that we do not merely know, but &lt;strong&gt;have control over every possible implementation of the interface&lt;/strong&gt;. That control can cover different properties, including, importantly for us, &lt;strong&gt;the enforcement of immutability!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is what &lt;a href="https://github.com/axelixlabs/axelix/blob/master/master/src/main/java/com/axelixlabs/axelix/master/mcp/McpEndpoint.java" rel="noopener noreferrer"&gt;&lt;code&gt;McpEndpoint&lt;/code&gt; in Axelix&lt;/a&gt; looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;McpEndpoint&lt;/span&gt; &lt;span class="n"&gt;permits&lt;/span&gt; &lt;span class="nc"&gt;DefaultMcpEndpoint&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;name&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 only permitted implementation is a &lt;a href="https://github.com/axelixlabs/axelix/blob/master/master/src/main/java/com/axelixlabs/axelix/master/mcp/DefaultMcpEndpoint.java" rel="noopener noreferrer"&gt;&lt;code&gt;record&lt;/code&gt;&lt;/a&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="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;DefaultMcpEndpoint&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="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;McpEndpoint&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;What does this combination give us?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Arbitrary code cannot add an unknown implementation of &lt;code&gt;McpEndpoint&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;As I said earlier, we control all permitted implementations.&lt;/li&gt;
&lt;li&gt;The current implementation is a &lt;code&gt;record&lt;/code&gt;, so its components cannot be reassigned after creation.&lt;/li&gt;
&lt;li&gt;Its only component is a &lt;code&gt;String&lt;/code&gt;, which is itself immutable.&lt;/li&gt;
&lt;li&gt;The record generates component-based &lt;code&gt;equals&lt;/code&gt; and &lt;code&gt;hashCode&lt;/code&gt; implementations, based here on the stable &lt;code&gt;name&lt;/code&gt; component.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As a result, every &lt;code&gt;McpEndpoint&lt;/code&gt; that can currently be placed into the &lt;code&gt;Map&lt;/code&gt; has the key semantics we need.&lt;/p&gt;

&lt;p&gt;And this is an important, though not entirely obvious, use case for a sealed interface: &lt;strong&gt;we close the hierarchy because the algorithm relies on the properties of every implementation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is fair to say that we are effectively relying on an implementation property while working with a contract, which is a violation of SOLID. &lt;strong&gt;That is all true&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Nevertheless, sealed types are, by their very nature, designed around knowing the complete set of implementations in advance and doing things such as exhaustive &lt;code&gt;switch&lt;/code&gt; statements. Strictly speaking, that also violates SOLID because we rely on implementations instead of working purely with an abstraction. There is room for a productive discussion here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Use an &lt;code&gt;enum&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;That is a natural question. If the set of MCP endpoints is fixed, why not simply do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;McpEndpoint&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;CLEAR_ALL_CACHES&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"clearAllCaches"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;CLEAR_SPECIFIC_CACHE&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"clearSpecificCacheEntity"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;

    &lt;span class="c1"&gt;// and so on...&lt;/span&gt;
    &lt;span class="o"&gt;;&lt;/span&gt;

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

    &lt;span class="nc"&gt;McpEndpoint&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;toolName&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;toolName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;toolName&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;This is a perfectly valid alternative. Moreover, for a permanently fixed set of endpoints, an &lt;code&gt;enum&lt;/code&gt; would provide an even stronger guarantee: not only the implementations but also &lt;strong&gt;the instances&lt;/strong&gt; themselves would form a closed set.&lt;/p&gt;

&lt;p&gt;So why might the interface still make sense? Because for us enums are too inflexible and, in particular, cannot be extended. Different Axelix Master distributions may have different &lt;code&gt;McpEndpoint&lt;/code&gt; instances, while an enum could not be extended to accommodate them.&lt;/p&gt;

&lt;p&gt;Nevertheless, always remember that &lt;strong&gt;extensibility is not free. Leave an extension point only where you genuinely need one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is also important to remember that the sealed model does not guarantee:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unique endpoint names;&lt;/li&gt;
&lt;li&gt;registration of every new endpoint in all &lt;code&gt;Map&lt;/code&gt; mappings of this kind;&lt;/li&gt;
&lt;li&gt;correctness of the authority mapping itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Did you add a new constant and forget to register it in one of the resolvers? &lt;code&gt;javac&lt;/code&gt; will not save you here. That requires different data models and tests.&lt;/p&gt;

&lt;p&gt;In our particular case, &lt;code&gt;sealed&lt;/code&gt; solves one particular problem. And that is fine. We should not expect a language feature to wash the dishes and deploy a release in-between.&lt;/p&gt;

&lt;p&gt;In other words, this is a balance between extensibility and immutability.&lt;/p&gt;

&lt;h2&gt;
  
  
  What These Two Features Have in Common
&lt;/h2&gt;

&lt;p&gt;Now let's return to the beginning of the article.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ScopedValue&lt;/code&gt; and a &lt;code&gt;sealed interface&lt;/code&gt; look like completely different Java features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ScopedValue&lt;/code&gt; works with the execution context.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;sealed interface&lt;/code&gt; works with the type system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But in our two use cases, they solve the same engineering problem: &lt;strong&gt;they reduce the set of valid program states&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;ScopedValue&lt;/code&gt;, we say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A value can be bound to a &lt;code&gt;ScopedValue&lt;/code&gt; only for a bounded period of time. Code further down can read it but cannot arbitrarily overwrite it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With a &lt;code&gt;sealed interface&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The abstraction is available throughout the application, but only types under our control can implement it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This fits well with the overall direction of the platform that Java architects call &lt;a href="https://openjdk.org/jeps/8305968" rel="noopener noreferrer"&gt;&lt;code&gt;Integrity by Default&lt;/code&gt;&lt;/a&gt;: correct and safe behavior should be the default, while a dangerous exception should require an explicit decision.&lt;/p&gt;

&lt;p&gt;In my opinion, this is exactly how new language features should be evaluated. Not by the number of lines they allow us to remove, and not by how elegant they look in a conference talk. The real question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which invariant of my application does this feature allow me to express and protect?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If there is no answer, perhaps you do not need the feature yet. If there is an answer, then it is no longer a toy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Recommendations
&lt;/h2&gt;

&lt;p&gt;Let's sum things up.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Consider &lt;code&gt;ScopedValue&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;ScopedValue&lt;/code&gt; if:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Data only travels down the call chain.&lt;/li&gt;
&lt;li&gt;Intermediate methods should not have to accept it as a parameter.&lt;/li&gt;
&lt;li&gt;A callee should not replace the binding.&lt;/li&gt;
&lt;li&gt;The lifetime of the data matches a bounded operation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep using an ordinary parameter if the dependency is an important part of the method's contract. Keep using &lt;code&gt;ThreadLocal&lt;/code&gt; if you need mutation, legacy integration, or support for an older Java version.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Consider &lt;code&gt;sealed&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Use a sealed class or interface if:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The abstraction must be broadly visible.&lt;/li&gt;
&lt;li&gt;The set of implementations must remain under the author's control.&lt;/li&gt;
&lt;li&gt;The code relies on the properties of all permitted implementations.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Invariant Comes First, Then the Feature
&lt;/h3&gt;

&lt;p&gt;Do not go looking for somewhere to put a &lt;code&gt;ScopedValue&lt;/code&gt;, sealed class, record pattern, or virtual thread merely because you can. As everyone knows, we never do that, right? Right...?&lt;/p&gt;

&lt;p&gt;First, identify an actual constraint in the system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;this context must not leak beyond the request boundary;&lt;/li&gt;
&lt;li&gt;this binding must not be changed from below;&lt;/li&gt;
&lt;li&gt;this hierarchy must not be extended by unknown types;&lt;/li&gt;
&lt;li&gt;this object must have stable value semantics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only then should you choose the language feature that expresses that constraint.&lt;/p&gt;

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

&lt;p&gt;New Java features really are used in real-world applications. But their value does not appear when we replace old syntax with new syntax. It appears when an entire class of incorrect programs becomes harder to write or impossible to compile.&lt;/p&gt;

&lt;p&gt;In Axelix, &lt;code&gt;ScopedValue&lt;/code&gt; gave the security context a bounded lifetime and safe propagation down the call stack. A &lt;code&gt;sealed interface&lt;/code&gt; allowed us to keep the domain abstraction public while retaining control over the semantics of its implementations and still permitting new &lt;code&gt;McpEndpoint&lt;/code&gt; instances to be created.&lt;/p&gt;

&lt;p&gt;Both features reduced the room for error. And that, in my opinion, is one of the main things we want from a programming language.&lt;/p&gt;

&lt;p&gt;The source code is open, so you can &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;inspect all the examples directly in the Axelix repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Take care!&lt;/p&gt;

</description>
      <category>java</category>
      <category>systemdesign</category>
      <category>opensource</category>
    </item>
    <item>
      <title>One Branch to Rule Them All</title>
      <dc:creator>Sergei Cherkasov</dc:creator>
      <pubDate>Wed, 19 Aug 2026 16:09:22 +0000</pubDate>
      <link>https://dev.to/axelix-labs/one-branch-to-rule-them-all-4j8j</link>
      <guid>https://dev.to/axelix-labs/one-branch-to-rule-them-all-4j8j</guid>
      <description>&lt;p&gt;Hey everybody. This is Mikhail, the technical lead of the &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;Axelix project&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For quite a long time I wanted to write a small article outlining our git branching model, in hope it may be useful to other people.&lt;/p&gt;

&lt;p&gt;There are quite a lot of git branching strategies in the world, e.g.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow" rel="noopener noreferrer"&gt;Gitflow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/get-started/using-github/github-flow" rel="noopener noreferrer"&gt;GitHub-flow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://trunkbaseddevelopment.com/" rel="noopener noreferrer"&gt;Trunk-Based-Development (TBD)&lt;/a&gt; and so on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Throughout my experience I can tell, that teams usually do not adopt the given strategy in the exact form/shape, rather, they either invent something completely&lt;br&gt;
new themselves, or they just take, for instance, aforementioned GitFlow and modify it for their needs.&lt;/p&gt;

&lt;p&gt;So, throughout already a year of development of &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;Axelix&lt;/a&gt;, we have adopted a slightly modified Trunk Based Development model, and I think it served us well.&lt;br&gt;
I hope you will find it also useful. This model admittedly has certain downsides, but in general, we're happy with it.&lt;/p&gt;

&lt;p&gt;Before we start, you should be mindful of the requirements we tried to address. At the end of the day everybody must judge for themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small Prerequisite. Axelix Versioning.
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The two most difficult problems in software are naming and versioning.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not the exact quote, but it is a rough sense of the joke that &lt;a href="https://thomaswue.dev/" rel="noopener noreferrer"&gt;Thomas Wuerthinger&lt;/a&gt; (GraalVM Project Lead) said on BoF on Devoxx Belgium 2025 (explaining the &lt;a href="https://blogs.oracle.com/java/detaching-graalvm-from-the-java-ecosystem-train" rel="noopener noreferrer"&gt;&lt;em&gt;"Detaching GraalVM from the Java Ecosystem Train"&lt;/em&gt;&lt;/a&gt; article).&lt;/p&gt;

&lt;p&gt;To later understand on why we're doing certain things we're doing, it's going to be useful to realize the versioning scheme we're using.&lt;/p&gt;

&lt;p&gt;At Axelix, we use &lt;a href="https://semver.org/" rel="noopener noreferrer"&gt;semantic versioning&lt;/a&gt; of our components. I'd even say that SemVer is a de-facto standard for versioning the modern world. There are, of course, simple versioning schemes, like for Windows OS, when you&lt;br&gt;
have Windows 7, 8, 10 or 11. There also is software that is released under the different variations &lt;a href="https://calver.org/" rel="noopener noreferrer"&gt;CalVer&lt;/a&gt;. The JetBrains IDEs will be a good example of that,&lt;br&gt;
e.g. 2026.1 or 2026.2.&lt;/p&gt;

&lt;p&gt;Still, the majority of projects do SemVer, and Axelix does it as well. But when we're talking about the modern software - it is often composed of multiple components.&lt;br&gt;
For example, Axelix has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build plugins for Maven/Gradle&lt;/li&gt;
&lt;li&gt;Spring Boot starters&lt;/li&gt;
&lt;li&gt;Master as the separate bootable JAR&lt;/li&gt;
&lt;li&gt;Docker image of Master&lt;/li&gt;
&lt;li&gt;Helm Chart of Master&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, the thing is that each of the points above represent an independent artifact that is generally distributed independently of others, and thus, it has its own coordinates.&lt;/p&gt;

&lt;p&gt;And in our case, we have selected to follow the Lockstep Versioning of the Axelix project. In short, this is not the versioning schema, but a versioning strategy where all&lt;br&gt;
 components of the system share the same version X.Y.Z. It means, that if we release Axelix 1.4.2, all the components above receive the same version - 1.4.2.&lt;/p&gt;

&lt;p&gt;Yes, we know that Lockstep versioning is generally &lt;em&gt;a highly debatable subject for a couple of reasons&lt;/em&gt;. And I would say, that team should generally think twice before adopting it.&lt;br&gt;
I should probably write another article outlining why we choose semver with lockstep, and why it &lt;em&gt;may be&lt;/em&gt; a good idea for &lt;em&gt;some&lt;/em&gt; of you as well. But this is an off-top for this article.&lt;/p&gt;

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

&lt;p&gt;So, you're starting a new project, or you're about to revisit the git branching model in your team for various reasons. When you try to implement any technical solution (let's say, you want to choose a git branching model for the project), it is generally a good idea to take a step back and answer the question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What exactly is the problem that we're solving?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Indeed, before explaining anything, we should first realize what are the specific requirements that we want to address with any git branching model. And these requirements&lt;br&gt;
will be different for different kinds of software that has different release cadence. In the case of Axelix, we wanted a branching model that would:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Give us the ability to do patches
&lt;/h3&gt;

&lt;p&gt;We want to be able to deliver patches quickly.&lt;/p&gt;

&lt;p&gt;Moreover, the important thing is that &lt;em&gt;we do not know the amount of patch releases in the given minor in advance&lt;/em&gt;. The general&lt;br&gt;
requirement is to be able to go to the &lt;em&gt;arbitrary minor release&lt;/em&gt;, and apply &lt;em&gt;an arbitrary number of patches&lt;/em&gt; for it (for example, to fix the CVE).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Avoid the merge hell.
&lt;/h3&gt;

&lt;p&gt;A lot of git branching strategies easily end-up in the merge hell, e.g. GitFlow is infamous for ending up in such conundrums.&lt;/p&gt;

&lt;p&gt;So, with that in mind, let's finally discuss what branching model we actually adopt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Axelix Git Branching Model
&lt;/h2&gt;

&lt;p&gt;At Axelix, we have the monorepo. And in this monorepo we have the main trunk, called 'master' trunk (the thing we borrowed from TBD). Some commits that do&lt;br&gt;
polishing or basic housekeeping happen directly in the master trunk. For features, we create short-lived branches (directly from master) that gets merged&lt;br&gt;
back into the master trunk:&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%2Fnw5ly82eci3ciju7amhx.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%2Fnw5ly82eci3ciju7amhx.png" alt="Short-lived feature branches created from and merged back into the master trunk" width="800" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In some extreme cases, adepts of TBD advocate for no feature branches at all with the risk of them being long-lived, and I understand the rationale behind it.&lt;br&gt;
However, for OSS projects such as Axelix, we cannot allow for that because of external contributors and for various other reasons.&lt;/p&gt;

&lt;p&gt;Nevertheless, &lt;em&gt;feature branches ideally need to die fast&lt;/em&gt;. To facilitate "the death" of feature branches, we impose a couple of rules on the feature&lt;br&gt;
branches, and famously, we introduce a cap of 500-lines PR. In other words, feature branch cannot contribute more than 500 lines of code into the main trunk (there are some&lt;br&gt;
exceptions to that rule, but it does not matter right now). This cap ensures that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The PR is small enough to be carefully reviewed.&lt;/li&gt;
&lt;li&gt;The feature branch will indeed be short-lived, since the smaller PR the faster it will be ready for review.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It is generally well-known that &lt;a href="https://www.cubic.dev/blog/does-pr-size-actually-matter" rel="noopener noreferrer"&gt;for small PRs the overall TTM (time-to-merge) is smaller&lt;/a&gt;,&lt;br&gt;
and the cap ensures feature branches die fast and they do not evolve into the long-lived branches. So here, we're perfectly aligned with the TBD.&lt;/p&gt;

&lt;p&gt;When we want to perform a minor/major release, we create a tag on the master trunk of our monorepo and execute the release pipeline (very simplified):&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%2Fu0m8xxpfoxetj98ggw47.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%2Fu0m8xxpfoxetj98ggw47.png" alt="Tagging a commit on the master trunk to trigger a minor/major release" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Since we have the monorepo with the lockstep versioning, this tagged commit is the one from which all the release artifacts are built. And the value of the&lt;br&gt;
tag is the version that Axelix release gets.&lt;/p&gt;

&lt;p&gt;If we want to do the patch release, we create the branch from the git tag, e.g. a branch named 1.0.x. That is the long lived branch that is going to live by itself.&lt;br&gt;
Once we have created necessary patches in the 1.0.x branch, we create a tag in that long-lived patch branch, call this tag v1.0.1 e.g, and release it then:&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%2Fq88knqups4q67nc7d8js.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%2Fq88knqups4q67nc7d8js.png" alt="A long-lived 1.0.x patch branch created from a tag, with its own patch release tag" width="799" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here the pivot point where we significantly and deliberately depart from TBD. In TBD, it is forbidden to have long-lived branches apart from the main trunk.&lt;br&gt;
But I want to take a step back and explain not what is forbidden, but why TBD advocates against it, and why it is okay in our use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  TBD: Why patch-branches are okay.
&lt;/h2&gt;

&lt;p&gt;TBD is a well-known and proven git branching strategy. It &lt;a href="https://trunkbaseddevelopment.com/game-changers/#google-revealing-their-monorepo-trunk-2016" rel="noopener noreferrer"&gt;is adopted by Google, Facebook and other companies&lt;/a&gt;.&lt;br&gt;
And it does not allow long-lived (apart from the main trunk) branches for a variety of reasons. One of them, and, probably, the most notorious is the merging problem. Merging the long-lived branches is quite challenging and time-consuming,&lt;br&gt;
error-prone, bug-welcome and so on.&lt;/p&gt;

&lt;p&gt;This problem becomes real in scenarios, when we develop certain functionality, &lt;em&gt;that is intended to be later merged with something else, like a main trunk&lt;/em&gt;. But in case&lt;br&gt;
of such patch branches, we do not intend to merge them ever. &lt;em&gt;They are not supposed to be merged&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;According to SemVer, the changes that go into the patch releases are (very broadly):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backwards compatible bugfixes&lt;/li&gt;
&lt;li&gt;Backwards compatible perf. improvements&lt;/li&gt;
&lt;li&gt;Backports of CVE patches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Of course, we can discuss all day what exactly backward compatibility means, whether it accounts for "behavioral" backwards compatibility or source-level backwards compatibility and so on.&lt;br&gt;
Let's put that aside.&lt;/p&gt;

&lt;p&gt;The core idea is that &lt;em&gt;patch branches will never have any kind of features developed in them&lt;/em&gt;, that are going to be merged back into master trunk. That just won't happen.&lt;/p&gt;

&lt;p&gt;In our case, for example, the changes that end up in the patch branches are the very specific commits that just got cherry-picked from the main master trunk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenges &amp;amp; Problems
&lt;/h2&gt;

&lt;p&gt;Of course, such a model as above has challenges. There are a couple of them. Let me enumerate them.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Minor/Major Dilemma
&lt;/h3&gt;

&lt;p&gt;One of the most important challenges of such architecture is its Minor/Major inflexibility.&lt;/p&gt;

&lt;p&gt;So, what do I mean by that?&lt;/p&gt;

&lt;p&gt;Well, for example, let's say, that you're developing version 1.7 of your software. And now, you're thinking about releasing a new major version, 2.0. If you're&lt;br&gt;
making that decision, you're effectively starting to merge all the changes that you wanted to end up in the new major (e.g. non-backward-compatible ones, more on them&lt;br&gt;
later) into the master trunk. Well, once you've started doing that, pivoting back and saying stuff like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Oh, no, we changed our mind, we just want to release 1.8&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It becomes really hard, in some cases infeasible. That is the inflexibility I'm talking about.&lt;/p&gt;

&lt;p&gt;Still, that problem (in its various levels of severity) of having no simple return follows &lt;em&gt;a lot&lt;/em&gt; of similar git models, TBD included.&lt;br&gt;
So, in the industry, this problem is generally a consequence of the trade-off of not having merge hells.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Long-Lived Breaking Changes Branches
&lt;/h3&gt;

&lt;p&gt;Well, when I said to you, that we have master trunk and patch branches as the only long-lived branches, I have omitted one thing. One &lt;em&gt;very important thing&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And that is: our git branching model allows for long-lived &lt;em&gt;feature&lt;/em&gt; branches that contain breaking changes. And since such branches are &lt;em&gt;intended&lt;/em&gt; to be&lt;br&gt;
merged, we have to postpone them until the next major (at very least). And if we combine that with the problem above, then we realize - we're in trouble.&lt;/p&gt;

&lt;p&gt;Well, although we are in trouble, &lt;em&gt;the degree&lt;/em&gt; towards which we're actually in trouble is debatable.&lt;/p&gt;

&lt;p&gt;First of all, it significantly depends on the fact of &lt;em&gt;what changes we consider breaking&lt;/em&gt;. And for every&lt;br&gt;
mature system such changes may be different. In our case, we have a whole documentation page that explains our release cadence and overall breaking changes&lt;br&gt;
impression.&lt;/p&gt;

&lt;p&gt;Another thing is that we can &lt;em&gt;try&lt;/em&gt; to mitigate such problem by constraining the amount of breaking changes subject for the next release. It is not always&lt;br&gt;
feasible/predictable, but tackling that problem not from a technical, but from an administrative PoV may also help here.&lt;/p&gt;

&lt;p&gt;So, admittedly, this is also a challenge that we have to live with. We may try to mitigate that, but to some degree we're going to have it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusions. Axelix, Lessons Learned.
&lt;/h2&gt;

&lt;p&gt;So, again, I did not want to say that we have invented some astonishing or mind-blowing git model. Internally, we just felt that it may be worth sharing with&lt;br&gt;
the world the git branching model we adopted and the reasons why.&lt;/p&gt;

&lt;p&gt;The goal of the article was more to outline the way we approached the problem with the hope that you'll learn from it. Axelix has the &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;Open Source core&lt;/a&gt;,&lt;br&gt;
so if you want to learn more - you're welcome.&lt;/p&gt;

&lt;p&gt;Remember that there're always trade-offs, and sometimes it is okay to diverge from certain well-established practices. Still, you always have to&lt;br&gt;
clearly justify such divergences and, what is also important, realize the weaknesses of your solution as well.&lt;/p&gt;

&lt;p&gt;Take care!&lt;br&gt;
Mikhail&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Axelix goes GA. A journey of a thousand miles begins with a single step</title>
      <dc:creator>Sergei Cherkasov</dc:creator>
      <pubDate>Tue, 11 Aug 2026 12:39:57 +0000</pubDate>
      <link>https://dev.to/axelix-labs/axelix-goes-ga-a-journey-of-a-thousand-miles-begins-with-a-single-step-5hfj</link>
      <guid>https://dev.to/axelix-labs/axelix-goes-ga-a-journey-of-a-thousand-miles-begins-with-a-single-step-5hfj</guid>
      <description>&lt;p&gt;On behalf of the core Axelix team, and everybody who has contributed to the community, I want to declare: we finally did it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Axelix, finally, &lt;a href="https://github.com/axelixlabs/axelix/releases/tag/v1.0.0" rel="noopener noreferrer"&gt;goes GA (Generally Available)!&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For those who do not know - Axelix is a product with an Open Source core, that allows you to discover the common problems, pitfalls and inefficiencies in Java applications at large scale.&lt;/p&gt;

&lt;p&gt;We're available &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt; (btw - give us a star!).&lt;/p&gt;

&lt;p&gt;In this post, I want to share the story and the motivation behind the product overall. I hope you find it interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Story. Big "Why" Behind Axelix
&lt;/h2&gt;

&lt;p&gt;Java is quite an interesting language and ecosystem in general. I think a lot of people will not argue that it is quite old, and it was one of the first so-called "Object-Oriented" languages, that actually gained massive adoption. It both was, and it still is the backbone of modern enterprise.&lt;/p&gt;

&lt;p&gt;For anyone who claims that Java is dead - I recommend checking the &lt;a href="https://devecosystem-2025.jetbrains.com/" rel="noopener noreferrer"&gt;JetBrains State of Developer Ecosystem survey&lt;/a&gt; or even the &lt;a href="https://survey.stackoverflow.co/2025/technology" rel="noopener noreferrer"&gt;Stack Overflow survey for 2025&lt;/a&gt; (and Stack Overflow has, sadly, become a part of history). It is clear that Java as a language and the "ecosystem" around it (including Kotlin) is still relatively popular, and it remains true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ecosystems around Languages
&lt;/h2&gt;

&lt;p&gt;The experienced developer knows that today's ecosystems that evolve around languages are typically very diverse. For example, let's talk about JavaScript. If we decide to run JavaScript on the server, then we're probably going to work with a database of some sort. Therefore, we're also going to need a framework, a library to work with the database, e.g. an ORM (I know that we may work without it but let's leave that aside).&lt;/p&gt;

&lt;p&gt;And in JavaScript, we have quite a lot of options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prisma&lt;/li&gt;
&lt;li&gt;TypeORM&lt;/li&gt;
&lt;li&gt;DrizzleORM&lt;/li&gt;
&lt;li&gt;Kysely and so on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We can pretty safely state that &lt;a href="https://www.prisma.io/blog/how-prisma-orm-became-the-most-downloaded-orm-for-node-js" rel="noopener noreferrer"&gt;Prisma ORM is &lt;em&gt;probably&lt;/em&gt; the most used ORM on JavaScript&lt;/a&gt;. But notice that it is far from being the definitive JavaScript ORM.&lt;/p&gt;

&lt;p&gt;It is not like Prisma is the default choice and is by far the most popular ORM - it is just not the case. I want to emphasize - even by Prisma's own assessment of its popularity, it is not far ahead of its competitors. They are all very close. &lt;strong&gt;So, the way people work with the database in JavaScript may differ heavily from team-to-team.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The defining trait of Java Ecosystem
&lt;/h2&gt;

&lt;p&gt;In Java, if we're going to take the same problem (working with the database), then we will quickly realize, that the industry is heavily concentrated.&lt;/p&gt;

&lt;p&gt;We generally can state that Spring Data JPA as a solution around Hibernate occupies above 50% of all the data access solutions, let alone ORMs. I remember some time ago Josh Long, the much-loved Developer Advocate for Spring, posted a survey on X (formerly Twitter) about the data access technologies usage:&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-1528655715933597697-638" src="https://platform.twitter.com/embed/Tweet.html?id=1528655715933597697"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-1528655715933597697-638');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=1528655715933597697&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;We must of course acknowledge that there are fast-growing alternative solutions to JPA that are explored, such as jOOQ, but Spring Data JPA dominates the field. So, the way we work with the database in Java (at least on the server side) is relatively well-defined and revolves around Spring Data JPA.&lt;/p&gt;

&lt;p&gt;But there is more to it, it is not just the ORMs or data-access libraries. It's something bigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring Framework as the Phenomenon
&lt;/h2&gt;

&lt;p&gt;We cannot talk about Java ecosystem and not talk about Spring Framework. If we're going to look in the &lt;a href="https://devecosystem-2025.jetbrains.com/" rel="noopener noreferrer"&gt;aforementioned Developer Ecosystem State report from JetBrains&lt;/a&gt;, we're going to get the following insights. I have assembled them carefully for you - share them freely as you want. Also note that the respondents had multiple choices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Users&lt;/th&gt;
&lt;th&gt;Share of framework users&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Spring Framework&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3,042&lt;/td&gt;
&lt;td&gt;87.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ktor&lt;/td&gt;
&lt;td&gt;362&lt;/td&gt;
&lt;td&gt;10.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quarkus&lt;/td&gt;
&lt;td&gt;287&lt;/td&gt;
&lt;td&gt;8.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other&lt;/td&gt;
&lt;td&gt;210&lt;/td&gt;
&lt;td&gt;6.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vaadin&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;3.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Micronaut&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;2.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grails&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;1.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Helidon&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;1.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is (at least AFAIK) one of the most recent and most trusted data we have. Many thanks to JetBrains for publishing it.&lt;/p&gt;

&lt;p&gt;So, yes, although other ecosystems are also growing, e.g. Quarkus along with its Quarkiverse, but they still have a very, &lt;em&gt;very&lt;/em&gt; long way to catch up with Spring Framework. So at the end of 2025 (I wager that even today) we can safely state:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The most common Java app, which &lt;strong&gt;by far&lt;/strong&gt; represents more than half of Java server side deployments is a Spring Framework app.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Again, it is just data, you can download it and check yourself.&lt;/p&gt;

&lt;p&gt;But even if we consider different ecosystems: Spring Framework, Quarkus or Micronaut etc - we're going to quickly realize that these are not just "libraries" or "frameworks" in a traditional sense. &lt;strong&gt;They are themselves large frameworks that build an entire sub-ecosystem around them&lt;/strong&gt;. For example, are you working with Data? Well, you have&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spring Data in Spring&lt;/li&gt;
&lt;li&gt;Jakarta Data in Quarkus&lt;/li&gt;
&lt;li&gt;Micronaut Data in Micronaut&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Oh, you want to send an HTTP request? The most common thing on the backend! Well, we have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spring Cloud Exchange/OpenFeign in Spring&lt;/li&gt;
&lt;li&gt;RestClients in Quarkus&lt;/li&gt;
&lt;li&gt;Declarative HTTP Clients in Micronaut&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice that every framework has its own solution.&lt;/p&gt;

&lt;p&gt;In other ecosystems, e.g. in JavaScript on the server side, we may use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ExpressJS for web server&lt;/li&gt;
&lt;li&gt;Axios for sending requests&lt;/li&gt;
&lt;li&gt;Prisma as an ORM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And these are all just individual choices made by the team. We may use Axios or we may use Got. We may use Prisma or we may use TypeORM. Some solutions are more popular than others, but they are all independent - they exist outside of a single umbrella, like Spring Framework, for example.&lt;/p&gt;

&lt;p&gt;To put it simply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In the Java world, if we have any kind of problem, most likely, we &lt;strong&gt;do NOT&lt;/strong&gt; need to seek the solution on GitHub, find some fork that does what you want (which is a common JavaScript practice btw). There is almost 100% chance that Spring Framework has a solution for us already.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why am I telling you about this?
&lt;/h2&gt;

&lt;p&gt;Because it is important to understand, that since Java Ecosystem is so solidified around the Spring Framework technological stack, &lt;strong&gt;it often suffers the same problems in production&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What are these problems? Well, there are a lot of them. And we know all of them with our very own butts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Our connection pool leaks&lt;/li&gt;
&lt;li&gt;Our API endpoints are performing slow (maybe the problem is in the database?)&lt;/li&gt;
&lt;li&gt;We accidentally expose &lt;code&gt;/actuator/env&lt;/code&gt; in production and leak secrets&lt;/li&gt;
&lt;li&gt;Our applications consume too much memory and start very slowly...&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And so on. And the industry, throughout decades of Java experience in production, has developed a set of recommendations, best-practices, "solutions", guidelines. And these guidelines exist on different levels of the application, e.g:&lt;/p&gt;

&lt;p&gt;Guidelines for Hibernate/ORM&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://thorben-janssen.com/entity-mappings-introduction-jpa-fetchtypes/" rel="noopener noreferrer"&gt;Do not use &lt;code&gt;EAGER&lt;/code&gt; fetch, ever!&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thorben-janssen.com/avoid-cascadetype-delete-many-assocations/" rel="noopener noreferrer"&gt;Do not use &lt;code&gt;CascadeType.ALL&lt;/code&gt; or &lt;code&gt;CascadeType.REMOVE&lt;/code&gt; for "XToMany"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thorben-janssen.com/best-practices-for-many-to-many-associations-with-hibernate-and-jpa/" rel="noopener noreferrer"&gt;Do not use &lt;code&gt;List&lt;/code&gt; for &lt;code&gt;@ManyToMany&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thorben-janssen.com/hibernate-tips-unidirectional-one-to-many-association-without-junction-table/" rel="noopener noreferrer"&gt;Do not use one-directional &lt;code&gt;@OneToMany&lt;/code&gt;!&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and so on. Guidelines for Spring performance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.reddit.com/r/java/comments/1hcgyc4/the_opensessioninview_pattern_of_spring_boot_a/" rel="noopener noreferrer"&gt;Disable OSIV&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Use &lt;a href="https://rieckpil.de/achieve-faster-build-times-with-the-spring-test-profiler/" rel="noopener noreferrer"&gt;Spring Test Profiler!&lt;/a&gt; (many thanks to Philip Riecks for such a tool)&lt;/li&gt;
&lt;li&gt;Use &lt;a href="https://github.com/seregamorph/spring-test-smart-context" rel="noopener noreferrer"&gt;Spring smart context caching!&lt;/a&gt; (Sergey Chernov, a highly experienced Engineer and a buddy of mine, developed the tool)!&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vladmihalcea.com/log-sql-spring-boot/" rel="noopener noreferrer"&gt;Do not set &lt;code&gt;spring.jpa.show-sql=true&lt;/code&gt; or other dangerous properties in production!&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and so on. Guidelines for even our beloved Java!&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For Christ’s sake, &lt;a href="https://openjdk.org/projects/leyden/" rel="noopener noreferrer"&gt;use Project Leyden&lt;/a&gt;!&lt;/li&gt;
&lt;li&gt;Use Compact Object Headers (&lt;a href="https://openjdk.org/projects/lilliput/" rel="noopener noreferrer"&gt;Project Lilliput&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Configure the &lt;a href="https://blog.gceasy.io/java-garbage-collection-best-practices/" rel="noopener noreferrer"&gt;logging of GC&lt;/a&gt; in production!&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And so on and on and on. Because we have that solidified ecosystem we generally know &lt;em&gt;"what is right and what is wrong"&lt;/em&gt;. And for the long time there was no such tool.&lt;/p&gt;

&lt;p&gt;But we thought that this is wrong. And we probably should have one. And this tool is called &lt;strong&gt;Axelix&lt;/strong&gt; (&lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;The source code&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In 1.0.0, we do not support the detection of all the issues that I outlined above (most, but not all). And the above is just an example. There is a lot (really, &lt;em&gt;a lot&lt;/em&gt;) of knowledge that we as a Java community have accumulated throughout these years that we're going to put inside Axelix, so it can serve you well.&lt;/p&gt;

&lt;p&gt;I hope, at that point, the motivation behind the tool is clear, and now, I want to answer a couple of questions that we already have received many times over during Milestones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: Is it free?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Yes, it is&lt;/strong&gt;. We're going to follow the Open Core model. This is the Open Source software development model that underpins projects that we all love and care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grafana&lt;/li&gt;
&lt;li&gt;Keycloak&lt;/li&gt;
&lt;li&gt;Vaadin&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And so on. It is &lt;a href="https://opencoreventures.com/insights/someone-has-to-pay-for-the-domino/" rel="noopener noreferrer"&gt;generally considered to be the most reliable model for the OSS development&lt;/a&gt;, and that is why industry leaders choose, and that is why we choose it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: Is the project ready for use and deploy?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Yes&lt;/strong&gt;, it is ready for use free of charge. We have processed the feedback from literally tens of teams that have installed Axelix milestone releases and shared their insights with us.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: Is it safe to deploy in production?
&lt;/h3&gt;

&lt;p&gt;It is designed for production, so the answer is yes. But since this is the first 1.0.0 GA release, the general recommendation is to first install it on development environments, and then for production.&lt;/p&gt;

&lt;p&gt;That said, we do have teams that deployed Axelix in production already to get insights about their &lt;code&gt;@Transactional&lt;/code&gt; methods behavior and properties resolution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bottom Line:&lt;/strong&gt; The core stays Open Source always, and it always will be free of charge, and it already provides value for the teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Are We Headed?
&lt;/h2&gt;

&lt;p&gt;The team behind Axelix has many years of experience writing and optimizing Spring Boot applications. We have a lot of work to do ahead of us, and we still have a lot of things to incorporate into Axelix.&lt;/p&gt;

&lt;p&gt;For those who want to know how the project is installed &amp;amp; configured, please, visit our &lt;a href="https://axelix.io" rel="noopener noreferrer"&gt;website&lt;/a&gt; and also our &lt;a href="https://axelix.io/docs" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;. In case of any questions or difficulties, you can always reach out to the team at: &lt;a href="mailto:hello@axelix.io"&gt;hello@axelix.io&lt;/a&gt; We, the maintainers, are happy to answer your questions and assist you during the installation.&lt;/p&gt;

&lt;p&gt;We're also going to stay committed to Open Source, and committed to our values, which are making sure that your Java applications are as secure, performant and efficient as they can be.&lt;/p&gt;

&lt;p&gt;Thank you all. Mikhail.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>java</category>
      <category>spring</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Taming Hibernate in Tests</title>
      <dc:creator>Sergei Cherkasov</dc:creator>
      <pubDate>Sat, 08 Aug 2026 07:51:22 +0000</pubDate>
      <link>https://dev.to/axelix-labs/taming-hibernate-in-tests-hl6</link>
      <guid>https://dev.to/axelix-labs/taming-hibernate-in-tests-hl6</guid>
      <description>&lt;p&gt;Hi everyone! This is Mikhail Polivakha, the technical lead of the &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;Open Source Axelix project&lt;/a&gt; (a project dedicated to helping you identify common problems in Java server-side applications, including while working with Hibernate).&lt;/p&gt;

&lt;p&gt;In this article, I want to address the question that I think is pretty common amongst the advanced Hibernate users:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Is there a verify Hibernate behavior in tests (like Junit for example)?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here it's really important not to hand you a "fish", but to teach you how to fish - that is, to give you a framework of what's worth keeping in mind when working with Hibernate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controlling Hibernate during tests. Overview.
&lt;/h2&gt;

&lt;p&gt;If I asked you: &lt;strong&gt;guys, which problems in Hibernate annoy you the most?&lt;/strong&gt; You'd mention things like the sneaky N + 1, In Memory Pagination (when explicitly specifying &lt;code&gt;setMaxResult&lt;/code&gt; or a &lt;code&gt;Pageable&lt;/code&gt;), a Cartesian Product loaded into the application's memory, or, say, some baffling swarm of &lt;code&gt;SELECT&lt;/code&gt;s followed by &lt;code&gt;UPDATE&lt;/code&gt;s/&lt;code&gt;DELETE&lt;/code&gt;s under the hood, and so on.&lt;/p&gt;

&lt;p&gt;Everything I listed above, and really all "problems" with Hibernate, can be split into 2 categories. Let's break them down.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Problems at the ORM level
&lt;/h2&gt;

&lt;p&gt;There are situations that both we (the end users) and Hibernate identify as problems. For example, up until Hibernate 7.4, the query below would perform pagination entirely in memory. This is fairly well known:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="nd"&gt;@Query&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="s"&gt;"SELECT o FROM Owner o JOIN FETCH o.pets"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;countQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"SELECT COUNT(o) FROM Owner o"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Owner&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findAllWithPets&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Pageable&lt;/span&gt; &lt;span class="n"&gt;pageable&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In version 7.4 Hibernate learned to do pagination within a sub-select. But nevertheless, if you, for example, work on Spring Boot 3, you have Hibernate 6, and in the case of queries like the one above there will be a warning in stdout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it's there because Hibernate itself understands that its actions &lt;em&gt;may&lt;/em&gt; blow up your heap. There's even a property that we at Axelix recommend setting for new microservices that don't use 7.4 yet (and there are still many of those):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you set it, Hibernate will simply throw an exception where it would previously do In Memory Pagination.&lt;/p&gt;

&lt;p&gt;In short, what I want to say is that the problem above is understood by Hibernate as a real problem. It recognizes and admits it. That's one layer of problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But oddly enough, these are quite hard to track&lt;/strong&gt;. Hibernate rarely provides any means to hook into it (although there are some nuances, read below). For instance, at Axelix, to detect In Memory Pagination (if it happens), we simply &lt;a href="https://github.com/axelixlabs/axelix/blob/master/sbs/axelix-spring-boot-2-starter/src/main/java/com/axelixlabs/axelix/sbs/spring/core/persistence/hibernate/LogbackInMemoryPaginationAppender.java" rel="noopener noreferrer"&gt;literally catch an &lt;code&gt;ILoggingEvent&lt;/code&gt; and inspect the code within it&lt;/a&gt;. Because there are no other acceptable ways (at least none that we're aware of)!&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Second. Problems at the application level
&lt;/h2&gt;

&lt;p&gt;There's a conceptually different layer of problems. N + 1, for example, belongs to it.&lt;/p&gt;

&lt;p&gt;Let's think for a second: &lt;em&gt;what is N + 1?&lt;/em&gt;. Try right now, without looking anywhere, to clearly formulate the definition in your head. Done? Here's how I'd formulate it (informally):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It's a situation in which you iterate over a collection loaded by Hibernate, sequentially accessing some lazy field within each individual entity of the collection, thereby triggering "N" additional SELECT queries.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is, if you think about it a little, it becomes obvious that for Hibernate this is just Lazy Loading. The notion of N + 1 as a problem exists at the application level, not at the ORM level. &lt;strong&gt;That is, the problem is not lazy loading itself, but the semantics of lazy loading&lt;/strong&gt; (what does &lt;em&gt;this particular&lt;/em&gt; lazy loading mean? Is it just lazy loading, or lazy loading within an N + 1?).&lt;/p&gt;

&lt;p&gt;In such a situation it's obviously pointless to rely on Hibernate, since the N + 1 phenomenon happens at the application level, and the ORM is completely unaware of it — it's not its area of responsibility. You can't get by without fairly strong tooling around Hibernate. &lt;strong&gt;Can it be written, and if so, how?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If we're talking specifically about N + 1, then the answer is generally - yes, you can, and we did it at Axelix in Open Source. Let me tell you how exactly we did it. &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;The source code is open, take a look if you're interested&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The General Approach to N + 1 Detection
&lt;/h3&gt;

&lt;p&gt;Hibernate doesn't know what N + 1 is, but it does know what a Lazy Loading association is, and that moment can already be caught. For example, here's how we do it in &lt;a href="https://github.com/axelixlabs/axelix/blob/master/sbs/axelix-spring-boot-2-starter/src/main/java/com/axelixlabs/axelix/sbs/spring/core/persistence/hibernate/NPlusOneCollectionLoadListener.java" rel="noopener noreferrer"&gt;&lt;code&gt;NPlusOneCollectionLoadListener&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;    &lt;span class="nd"&gt;@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;onInitializeCollection&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InitializeCollectionEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;PersistentCollection&lt;/span&gt; &lt;span class="n"&gt;persistentCollection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCollection&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="nc"&gt;EventSource&lt;/span&gt; &lt;span class="n"&gt;eventSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSession&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="nc"&gt;CollectionPersister&lt;/span&gt; &lt;span class="n"&gt;collectionPersister&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;eventSource&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPersistenceContextInternal&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCollectionEntry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;persistentCollection&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLoadedPersister&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

            &lt;span class="nc"&gt;LazyLoadingTarget&lt;/span&gt; &lt;span class="n"&gt;lazyLoadingTarget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseLazyLoadingTarget&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collectionPersister&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRole&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;lazyLoadingTarget&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="n"&gt;transactionAccessor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recordLazyLoading&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lazyLoadingTarget&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;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// The general "role" format is expected to look like this: com.example.Order.items&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nd"&gt;@Nullable&lt;/span&gt; &lt;span class="nc"&gt;LazyLoadingTarget&lt;/span&gt; &lt;span class="nf"&gt;parseLazyLoadingTarget&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;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;try&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;separatorIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lastIndexOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="nc"&gt;Class&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ownerEntityClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separatorIndex&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;propertyName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;separatorIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;LazyLoadingTarget&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ownerEntityClass&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;propertyName&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ClassNotFoundException&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;IndexOutOfBoundsException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"Unexpected propertyPath format '{}'. Axelix cannot recognize that, so lazy loading and potential N + 1 is not going to be tracked for this property"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;role&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="c1"&gt;// it means that the role format is not the one that we expect&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;Here, through a series of manipulations, we can figure out which association was loaded lazily and on which collection. What do we then do with this information?&lt;/p&gt;

&lt;p&gt;Axelix, for example, currently makes the following decision: &lt;strong&gt;If we noticed that within a transaction there were several lazy loadings of the same association, then we consider this to be an N + 1.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Is that always correct? Well, this is debatable, because, for instance, in this code example, if we assume that &lt;code&gt;Order.items&lt;/code&gt; were loaded lazily — we, &lt;em&gt;seemingly&lt;/em&gt;, don't have an N + 1:&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;compareOrders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;previousId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;currentId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousId&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentId&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Two lazy accesses to Order.items, but there's no collection of orders here -&lt;/span&gt;
    &lt;span class="c1"&gt;// we're just comparing two specific versions of the same order&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="na"&gt;getItems&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getItems&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="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;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The order contents have changed"&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;Or do we? And what if I change this example to something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;compareOrders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;previousId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;currentId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findAllById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currentId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Exactly the same comparison as above, but now within an iteration&lt;/span&gt;
    &lt;span class="c1"&gt;// over a loaded collection of orders: at each step there's again a lazy&lt;/span&gt;
    &lt;span class="c1"&gt;// access to Order.items on both previous and current&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;var&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&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="na"&gt;getItems&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getItems&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="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;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The order contents have changed"&lt;/span&gt;&lt;span class="o"&gt;);&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="n"&gt;current&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;Does it look like N + 1 now? It does.&lt;/p&gt;

&lt;p&gt;So, in short, what I want to say is that here you need to clearly fix the definition of N + 1 at the application level. We at Axelix deliberately decided that this case is nonetheless worth treating as an N + 1, so, in the UI, it will be reported to you:&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%2Frp5l1stk8eqmyd15dyhv.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%2Frp5l1stk8eqmyd15dyhv.png" alt="Axelix reporting an N + 1 in the UI" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;P.S: Among other things, there's a small nuance here: N + 1, strictly speaking, can also happen &lt;strong&gt;without an open transaction&lt;/strong&gt; when OSIV is enabled. That's a separate case which we're not considering for now, since it would complicate the picture.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Micro-Conclusion
&lt;/h3&gt;

&lt;p&gt;I'd like to draw a small micro-conclusion here. In general, detecting some complex phenomena that occur at the application level while working with Hibernate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;N + 1&lt;/li&gt;
&lt;li&gt;Blocking calls inside transactions, and so on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This can be done. It just requires you to write a fairly large and non-trivial amount of tooling. Using N + 1 as an example, I hope you've roughly understood what such things will look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  General Hibernate Problems
&lt;/h2&gt;

&lt;p&gt;As a separate section, I'd like to address general Hibernate problems. Quite often people have some conditional hot-path for saving or updating data. And devs simply want to make sure that&lt;br&gt;
&lt;strong&gt;Hibernate doesn't "sabotage" the entire process undercover&lt;/strong&gt;. Sounds familiar, right?&lt;/p&gt;

&lt;p&gt;Here you need to take a step back and ask: &lt;strong&gt;and what do we mean by "sabotage"?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Having talked with people, we'll discover that most often people want roughly the following: to have confidence that a person wrote &lt;code&gt;repository.save()&lt;/code&gt;, and that it will execute specifically an &lt;code&gt;INSERT&lt;/code&gt; into the table and nothing more.&lt;/p&gt;

&lt;p&gt;For example, having this piece of code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OwnerService&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;OwnerRepository&lt;/span&gt; &lt;span class="n"&gt;ownerRepository&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;OwnerService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OwnerRepository&lt;/span&gt; &lt;span class="n"&gt;ownerRepository&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;ownerRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ownerRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Owner&lt;/span&gt; &lt;span class="nf"&gt;registerOwner&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;firstName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;lastName&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;owner&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;Owner&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lastName&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ownerRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&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 person wants to be sure that there won't be any hidden &lt;code&gt;SELECT&lt;/code&gt;s and so on there. I think some people recognized themselves. &lt;strong&gt;Can this be done?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer: &lt;strong&gt;generally yes, it can&lt;/strong&gt;. Hibernate has a Statistics API, which on the one hand isn't part of the JPA standard, and on the other hand can give us some details about query execution within a session. For example, for the code above you can write a test like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Requires hibernate.generate_statistics=true&lt;/span&gt;
&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OwnerServiceTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

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

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

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;registerOwner_issuesExactlyOneInsert&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// given&lt;/span&gt;
        &lt;span class="nc"&gt;Statistics&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entityManagerFactory&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unwrap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SessionFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatistics&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clear&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// when&lt;/span&gt;
        &lt;span class="n"&gt;ownerService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerOwner&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"John"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Doe"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// then&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEntityInsertCount&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEntityUpdateCount&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isZero&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEntityDeleteCount&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isZero&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This API lets you verify that only one &lt;code&gt;INSERT&lt;/code&gt; was executed within the method. It works based on the fact that in the test I opened a transaction (Spring Boot Test of course recognizes this and opens a transaction that will have to roll back at the end),&lt;br&gt;
thereby opening a Hibernate &lt;code&gt;Session&lt;/code&gt; - this is the default behavior, so I think there are no surprises here.&lt;/p&gt;

&lt;p&gt;Since the Statistics API collects statistics at the &lt;code&gt;SessionFactory&lt;/code&gt; level, not at the level of individual &lt;code&gt;Session&lt;/code&gt;s, in each test we have to access the &lt;code&gt;SessionFactory&lt;/code&gt; in order to first clear all its statistics.&lt;br&gt;
And only then can we make the checks we're interested in (Strictly speaking, some telemetry is also collected at the &lt;code&gt;Session&lt;/code&gt; level, but it's rather scarce and in practice can help you little).&lt;/p&gt;

&lt;p&gt;Overall, the telemetry collected (the number of &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; statements, and so on) will be enough for you to make some basic checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusions
&lt;/h2&gt;

&lt;p&gt;In practice, the answer to the question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Is there a verify Hibernate behavior in tests (like Junit for example)?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Strongly depends on what exactly you want to detect. Some tooling can be written — for example, Axelix can detect both In Memory Pagination, and N + 1, and a number of other problems. In some situations tests will help you a lot.&lt;br&gt;
In general, first of all it's better to ask yourself — &lt;strong&gt;what problem exactly are we trying to solve here&lt;/strong&gt;. And only then build a solution: is it possible (considering what I wrote above) to write an effective test,&lt;br&gt;
is it possible to delegate this to external tooling, or maybe (the ideal case) the problem doesn't need to be solved in the first place.&lt;/p&gt;

&lt;p&gt;Best of luck to everyone!&lt;/p&gt;

</description>
      <category>database</category>
      <category>testing</category>
      <category>springboot</category>
      <category>hibernate</category>
    </item>
    <item>
      <title>Natural IDs in your database. I am telling you the last time!</title>
      <dc:creator>Mikhail Polivakha</dc:creator>
      <pubDate>Fri, 24 Jul 2026 10:42:21 +0000</pubDate>
      <link>https://dev.to/axelix-labs/natural-ids-in-your-database-i-am-telling-you-the-last-time-13nc</link>
      <guid>https://dev.to/axelix-labs/natural-ids-in-your-database-i-am-telling-you-the-last-time-13nc</guid>
      <description>&lt;p&gt;Hi everyone! This is Mikhail Polivakha, tech lead of the &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;Axelix project&lt;/a&gt; (btw, give us a star!). In my experience consulting teams that build enterprise applications, I keep getting asked:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What about natural keys in the database? Say I have a column that lets me explicitly identify a record, should I use it as the Primary Key?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And over my years of designing enterprise systems, and over the time spent designing &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;Axelix&lt;/a&gt;, I've come to a conclusion: &lt;strong&gt;just never use natural keys, ever.&lt;/strong&gt; When you feel the urge to do it, step outside, take a walk, get some fresh air, and it'll pass.&lt;/p&gt;

&lt;p&gt;I understand this answer is categorical, so I'll add a couple of clarifications about what to do if you do happen to have a unique discriminating column that, &lt;em&gt;as it seems to you&lt;/em&gt;, lets you uniquely identify a record in a database table. The full, nuanced answer is of course more complicated, but if I have to give you a straight TL;DR:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When designing new systems, in my opinion, you should &lt;strong&gt;always&lt;/strong&gt; use surrogate primary keys.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now let's get into why I think so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules Written in Blood
&lt;/h2&gt;

&lt;p&gt;Rules like the one above are usually born out of getting burned on real projects several times. I have an absolutely perfect story straight from Open Source Axelix. The &lt;a href="https://github.com/axelixlabs/axelix" rel="noopener noreferrer"&gt;source code is on GitHub&lt;/a&gt;, so if you feel like it, go and check for yourself. I won't go too deep into the details, but so that you grasp the depth of the problem, I'll give you a bit of context. In some places I'll also deliberately simplify the parts I consider non-essential to understanding the problem.&lt;/p&gt;

&lt;p&gt;At its core, Axelix consists of two components. The first is &lt;strong&gt;Master&lt;/strong&gt;, a standalone application that acts as the "brain" of the system. It's deployed either in a K8S cluster, or launched as a separate Docker container, or even just run as a plain JAR.&lt;/p&gt;

&lt;p&gt;Master aggregates information from your Spring Boot services and stores it in its database. This information is later used to understand the "maturity" of your ecosystem, the distribution of versions of key components (for example, Spring Boot or Java versions), tracking known tech-debt issues, and so on.&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%2Fimmbypulpqf0x5xbfcdj.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%2Fimmbypulpqf0x5xbfcdj.png" alt="Axelix Architecture" width="799" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If we picture a typical company, they usually have a K8S/OpenShift cluster where their production runs. Almost always, a given application is deployed to production not as a single copy, but as a set of Instances (a K8S Deployment + a configured HPA, and so on). So in practice we have one logical application that is physically a set of different containers.&lt;/p&gt;

&lt;p&gt;Now I think we have enough context to discuss the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Beginning. Natural Keys: Sure, Why Not!
&lt;/h2&gt;

&lt;p&gt;As I said, Axelix stores data in its database to understand the overall state of your application. Let's call this table "Application" (in reality this abstraction is named differently in Axelix, but again, I'm simplifying heavily). This is where application-level data lives.&lt;/p&gt;

&lt;p&gt;Master can collect data from Spring Boot microservices via both a push and a pull model, but regardless of the model, &lt;strong&gt;it collects data at the Instance level, not at the Application level, i.e. not for the whole application.&lt;/strong&gt; So Master polls each Instance, and it's then Master's job to &lt;strong&gt;somehow figure out&lt;/strong&gt; that all those Instances belong to the same application.&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%2Foxh6y64qad1lbo56ax0e.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%2Foxh6y64qad1lbo56ax0e.png" alt="Axelix Instances Discovery" width="800" height="808"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The question is: &lt;strong&gt;How is Master supposed to do that?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;How does it figure out that these Instances belong to the same application? (Don't forget: Axelix isn't always deployed in K8S. Relying on ClusterIP services and the like is not an option.)&lt;/p&gt;

&lt;p&gt;Actually, if you think about it a little, the solution is right on the surface: &lt;strong&gt;we can just aggregate information at the level of the GroupID/ArtifactID pair from the GAV coordinates (the standard format of a Maven distribution)&lt;/strong&gt;. After all, all the Instances are required to have the same GroupID/ArtifactID, right?&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%2Fsbgqos3pymldw0u2r79b.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%2Fsbgqos3pymldw0u2r79b.png" alt="Axelix Instances Grouping" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Broadly speaking, yes, that's true. Some might think we could key off other things, for example &lt;code&gt;spring.application.name&lt;/code&gt; or similar, but unfortunately that won't work, for a number of reasons. That's another story, though, and it's not important right now.&lt;/p&gt;

&lt;p&gt;So imagine we're designing such a relation in the database. Here's my question for you: &lt;strong&gt;what primary key would you want for a table like this?&lt;/strong&gt; When we designed the "Application" entity, it seemed right to make the &lt;code&gt;{groupId/artifactId}&lt;/code&gt; pair the primary key, i.e. a Natural Composite Key.&lt;/p&gt;

&lt;p&gt;And it's so convenient! When information about some Instance arrives in Axelix Master (whether via the push or the pull model):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We can update the data with a simple ANSI SQL &lt;code&gt;MERGE&lt;/code&gt; or &lt;code&gt;INSERT ... ON CONFLICT DO ...&lt;/code&gt;, because artifactId/groupId is the primary key! &lt;a href="https://github.com/spring-projects/spring-data-relational" rel="noopener noreferrer"&gt;Spring Data JDBC (which we use as the ORM in Axelix Master) in 4.1&lt;/a&gt;
finally learned how to do UPSERTs on the primary key, and now we can just do this via &lt;code&gt;JdbcAggregateTemplate&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;reloadCurrentState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BasicRegistrationMetadata&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Application&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentSnapshot&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;jdbcAggregateTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;application&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;ul&gt;
&lt;li&gt;And how nicely it works out for the front-end! &lt;strong&gt;And here we arrive at the fact that a natural key carries business meaning by itself!&lt;/strong&gt; That, by the way, is one of the genuinely nice properties of natural keys. What do I mean? For example, in a situation where we just need to display the name of our "Application", and the name alone is enough, we can use the &lt;code&gt;artifactId&lt;/code&gt;, i.e. a part of the composite natural key. No need to "fetch anything extra", and so on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So where's the problem? Given everything I've said so far, is this problem really so critical that I claim you shouldn't use natural keys at all? &lt;strong&gt;Yes, it's that serious. And here's why.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So What's the Deal? A Bit of Philosophy
&lt;/h2&gt;

&lt;p&gt;The older a person gets, the more prone they are to doubting various things (for example, my claim in this article! And that's okay!). This is because people accumulate experience.&lt;/p&gt;

&lt;p&gt;People who have been doing engineering for a good while accumulate experience and come to understand just how much everything changes, and how much they still don't know (experienced engineers understand me 100% right now). A vendor comes and goes. So does an employee. &lt;em&gt;The uniqueness of a natural key...&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ray Dalio (an amazing person and macro investor, I highly recommend reading him) &lt;a href="https://www.principles.com/principles/ed86d768-d0b1-4b5c-bada-29592857b274/" rel="noopener noreferrer"&gt;wrote in his book "Principles"&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sincerely believe that you might not know the best possible path and recognize that your ability to deal well with "not knowing" is more important than whatever it is you do know.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is incredible wisdom. The idea is to accept the fact that your knowledge of the outside world is limited, and it will always be many times smaller than the set of things you don't know but which nonetheless affect your life/system/etc. And the most important thing in such a situation &lt;strong&gt;is to be able to work WITH YOUR OWN NOT-KNOWING of something, to hedge risks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;How does this relate to natural keys?&lt;/p&gt;

&lt;p&gt;Very simply: &lt;strong&gt;if some discriminator seems like an obvious key in the moment, just remember that the scope of your knowledge is incomparably small next to what you don't know. And that "invariant" you're pinning your hopes on, the one you think will be unique: it can very easily stop being unique half a year later.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What's more, the scope of your knowledge will keep growing. Over time, &lt;strong&gt;you (yes, you, my friend) grow as an engineer.&lt;/strong&gt; After a while you'll look at this code, or at the design of this system, and say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How on earth!? How could I have done this? This crap is just awful, it was obvious this key would break uniqueness in case X!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And it'll be obvious to you. &lt;strong&gt;But later.&lt;/strong&gt; When you become wiser. By the way, if you don't have these moments of "enlightenment" in your career, where you scold yourself for your own past decisions, that's a very strong warning sign that you've stopped growing as a specialist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to Engineering
&lt;/h2&gt;

&lt;p&gt;Let's get back a bit closer to the technical side.&lt;/p&gt;

&lt;p&gt;The main point of the previous section is that what seems like the uniqueness of a natural key today can easily stop being uniqueness later. Now let's think like engineers: &lt;strong&gt;how bad is that, really?&lt;/strong&gt; How bad is it that we'll be wrong about our natural key (composite or not, doesn't matter right now) turning out not to be unique?&lt;/p&gt;

&lt;p&gt;The truth is that a record's primary key must &lt;strong&gt;always (!)&lt;/strong&gt; have (among others) the following two distinguishing properties:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. It must be immutable
&lt;/h3&gt;

&lt;p&gt;When we assign a record some key by which we identify it, &lt;strong&gt;we then have no right to change it.&lt;/strong&gt; Why? Because the outside world that depends on our system stores exactly this ID, this primary key, to identify the record. It stores a reference, not the record itself.&lt;/p&gt;

&lt;p&gt;For example, imagine you have a third-party service that stores user profiles: &lt;code&gt;user-service&lt;/code&gt;. And there they decided to use email as the natural key. You write a service that orchestrates users' subscriptions to various services within the ecosystem. And now you need to fetch a user's profile from that &lt;code&gt;user-service&lt;/code&gt; system for your operations. How will you fetch it? By email, of course! It's the "unique key", after all.&lt;/p&gt;

&lt;p&gt;And now imagine that the Product Owner comes along and says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In our service we want to let a user change the email tied to their account.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is, effectively, an already-existing record in the database will have its identity changed. &lt;strong&gt;By changing a record's ID in this &lt;code&gt;user-service&lt;/code&gt;, any other system, including yours, can no longer find the profile it needs.&lt;/strong&gt; That would be a mass incident. That's why an ID must be immutable &lt;strong&gt;always.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. It must uniquely identify a record at any moment in time
&lt;/h3&gt;

&lt;p&gt;Now imagine that, all of a sudden, the folks from the team that develops &lt;code&gt;user-service&lt;/code&gt; get a requirement. They're told:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hey, we sometimes run into a situation where a user once created an account and tied their email to it. And now they want to somehow delete the old account (which they created some 10 years ago) and create a new one, and attach the same email to it. We wouldn't want to delete the old account (in enterprise, for various reasons, hard deletes are rarely done). So, shall we do it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here the problem is even more obvious. Not only can your system, which depends on &lt;code&gt;user-service&lt;/code&gt;, no longer find the right user profile (there could be several of them now!), all existing contracts break, and to "fix" them you'll have to "re-define" the ID (it's no longer unique, and you can't rely on the ID alone anymore). And if we have to change the ID, then see the section above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cause of Death: Natural Id
&lt;/h3&gt;

&lt;p&gt;Mistakes come in varying degrees of severity. There are mistakes that have a local effect and can be fixed relatively quickly and easily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But keys that identify data in distributed systems are something that spreads across the entire distributed system into its most varied corners&lt;/strong&gt;. So the moment you suddenly realize with horror that the natural id is no longer unique, the so-called blast radius will be fantastic. Especially in modern microservice architecture.&lt;/p&gt;

&lt;p&gt;So, friends, people die of different causes. Someone died of cancer, someone died of heart failure. And someone simply chose a Natural Id as their primary key, and then received an email in their inbox, or suddenly heard at a daily standup that the uniqueness assumption of this key was about to be shaken.&lt;/p&gt;

&lt;p&gt;I suggest a moment of silence before reading on, in memory of those engineers who paid the price for choosing Natural ID as their primary key…&lt;/p&gt;

&lt;p&gt;Thank you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Axelix Case
&lt;/h2&gt;

&lt;p&gt;Let's get back to the real case we had at Axelix.&lt;/p&gt;

&lt;p&gt;We haven't hit GA yet (it'll ship this summer, 2026, we're actively working on it), but we already have several Milestone releases. We embed with various companies to gather feedback, potential bugs, problems, and so on.&lt;/p&gt;

&lt;p&gt;And one company tells us:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You know, it just so happens that we essentially have two services: service A and service B. They're basically identical, just deployed in different network segments. They have the same groupId and artifactId. Nevertheless, service A is maintained by this team, and service B by that team.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I've simplified all the details, but this is the general message. So, we have a problem in this case - &lt;strong&gt;we can no longer identify an application the way we wanted, via the artifactId/groupId pair&lt;/strong&gt;. This is exactly what typically&lt;br&gt;
happens after a while, when the system is already deployed in production.&lt;/p&gt;

&lt;p&gt;Remember Ray Dalio!&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;... What exists within the area of "not knowing" is so much greater and more exciting than anything any one of us knows.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's precisely because of situation like this that you need to ask users to provide Axelix with the information about what the unique ID of a given application is themselves (for example, in &lt;code&gt;application.yaml&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  But Natural IDs Do Have Advantages...
&lt;/h2&gt;

&lt;p&gt;In my experience, the fact that a Natural ID carries business meaning that can be used somewhere (for example, displaying an application's name on the UI as the artifactId, as I already showed with the Axelix example) - &lt;strong&gt;this is solved simply by designing your API&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In other words, even with surrogate keys, you can design your API so that you don't have to fetch extra data from the backend; it's not a big problem (for example, get some metadata, put it into the state manager on the front-end, and so on; there are plenty of ways).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's really important about natural keys is that they force you to think about the invariants of your data&lt;/strong&gt;. That is, for example, logically, if your email is unique, then it makes sense to create an index on it (which is, for instance, what Postgres does when you ask it to create a Primary Key). And to avoid having two different indexes, why not make email the primary key, since in that case there would be just one index, only on email?&lt;/p&gt;

&lt;p&gt;That's a broadly valid argument, but I'll put it this way: it's not worth it. If you don't use email as a Natural ID, then whether or not to create a unique index on email is a decision to make case by case. &lt;strong&gt;I'd say that for 95%+ of cases the answer is definitely yes, and there won't be any problems with it.&lt;/strong&gt; That said, for large write-heavy systems with a lot of data, this may create a certain overhead, &lt;strong&gt;but again, usually negligible at the scale of the system.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And finally, regarding &lt;code&gt;MERGE&lt;/code&gt; / &lt;code&gt;INSERT ON CONFLICT&lt;/code&gt; operations. You can perfectly well do them not on the primary key, but on any constraint, for example, on a &lt;code&gt;UNIQUE&lt;/code&gt; constraint that you explicitly define in a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusions
&lt;/h2&gt;

&lt;p&gt;Based on my experience, I can tell you one thing: remember that the scope of your not-knowing is by nature far larger than the scope of your "knowing". That's why it's very dangerous to build an assumption that a Natural ID, which seems unique to you for a given record in the moment, will make a good Primary Key.&lt;/p&gt;

&lt;p&gt;That said, it's worth acknowledging that &lt;strong&gt;the main advantage of a Natural ID is that it forces you to think about what invariants your data has in general.&lt;/strong&gt; And these invariants should give you insights into how to model your data access and storage patterns, for example, defining unique b+tree indexes for the email column.&lt;/p&gt;

&lt;p&gt;Remember: indexes and things like that can later be removed without consequences for the whole system. Changing primary keys, on the other hand, is a dead end.&lt;/p&gt;

</description>
      <category>database</category>
      <category>java</category>
      <category>software</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
