<?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: Kamen</title>
    <description>The latest articles on DEV Community by Kamen (@kamenivanov).</description>
    <link>https://dev.to/kamenivanov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4043234%2Fc0c83e8a-752a-435b-b342-4a44ed85593a.png</url>
      <title>DEV Community: Kamen</title>
      <link>https://dev.to/kamenivanov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kamenivanov"/>
    <language>en</language>
    <item>
      <title>Bootstrapping the System, Part 1: Nothing Is a Bean Yet (Chapter 13)</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Mon, 12 Oct 2026 10:32:29 +0000</pubDate>
      <link>https://dev.to/kamenivanov/bootstrapping-the-system-part-1-nothing-is-a-bean-yet-chapter-13-2l4o</link>
      <guid>https://dev.to/kamenivanov/bootstrapping-the-system-part-1-nothing-is-a-bean-yet-chapter-13-2l4o</guid>
      <description>&lt;p&gt;Twelve chapters in, this project has a domain model, a persistence layer, a service layer, an exception handler and something close to a hundred tests, and yet there is no entry point to fire up everything.&lt;/p&gt;

&lt;p&gt;There is nothing to start. No main method, no application context, no datasource. &lt;code&gt;ProductsServiceImpl&lt;/code&gt; is a class with a constructor that takes two arguments, and the only place in the whole repository where those two arguments are ever supplied is a test. Spring has never seen any of it.&lt;/p&gt;

&lt;p&gt;That was intentionally made that way, and this chapter is where it gets paid for. Every class written so far is a plain Java class: no &lt;code&gt;@Service&lt;/code&gt;, no &lt;code&gt;@Repository&lt;/code&gt;, no &lt;code&gt;@Component&lt;/code&gt; on a single class in &lt;code&gt;domain&lt;/code&gt;, &lt;code&gt;dao-api&lt;/code&gt;, &lt;code&gt;dao-impl&lt;/code&gt; or &lt;code&gt;business-logic&lt;/code&gt;. The modules compile without Spring on the classpath, apart from the &lt;code&gt;@Transactional&lt;/code&gt; annotations the service layer carries as declarative metadata. Assembling them into a running application is a job for exactly one module, and the first decision here is that it is the only module allowed to do it.&lt;/p&gt;

&lt;p&gt;This first part is about the wiring: which module depends on what, where the beans are declared, and why the component scan is switched off in the one place it would have been most convenient. The settings those beans read, and where they come from, are the subject of Part 2.&lt;/p&gt;




&lt;h2&gt;
  
  
  The module that knows everything
&lt;/h2&gt;

&lt;p&gt;Every other module in this project is ignorant, &lt;code&gt;domain&lt;/code&gt; knows nothing, &lt;code&gt;dao-api&lt;/code&gt; knows the domain, &lt;code&gt;dao-impl&lt;/code&gt; knows &lt;code&gt;dao-api&lt;/code&gt; and Hibernate, &lt;code&gt;business-logic&lt;/code&gt; knows &lt;code&gt;dao-api&lt;/code&gt; and nothing about how the data gets stored. None of them knows what the others are, and none of them can reach across to find out.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;application&lt;/code&gt; module is the exception, and it has to be, because something has to hold the wiring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependencies&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;com.pe.advanced&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;business-logic&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;com.pe.advanced&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;dao-impl&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-web&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-actuator&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.flywaydb&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;flyway-mysql&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;com.mysql&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;mysql-connector-j&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependencies&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what this list says about the architecture. &lt;code&gt;business-logic&lt;/code&gt; does not depend on &lt;code&gt;dao-impl&lt;/code&gt;, so a service has no way to reach a JPA entity or a repository even by accident. The MySQL driver appears here and only here, which means the rest of the codebase could not name MySQL if it wanted to. &lt;code&gt;spring-boot-starter-web&lt;/code&gt; is the entry that will matter most later, and it is here because the controllers will be here. That sounds like the ordinary arrangement and it is not quite, because the controllers are only half of the REST layer. The other half is a module of its own, holding the interfaces that declare the endpoints together with the OpenAPI annotations that document them, and its entire dependency list is the Swagger annotations and Jackson. No Spring, no servlet API, nothing that implies a server. The controllers in this module implement those interfaces, and Spring reads the mapping annotations through the implementation.&lt;/p&gt;

&lt;p&gt;The reason for the split is who gets to depend on the contract. A module that compiles against two annotation libraries is something a client can take as a dependency and generate against, or implement against, without inheriting a web framework it has no use for. Collapse the two and the contract stops being publishable: anyone who wants to know what this service accepts has to depend on the thing that serves it. That module does not exist yet and neither do the controllers, which is why neither appears in this list.&lt;/p&gt;

&lt;p&gt;The Spring Boot plugin produces the executable jar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;plugin&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-maven-plugin&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;configuration&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;mainClass&amp;gt;&lt;/span&gt;com.pe.advanced.spring.AdvancedApplication&lt;span class="nt"&gt;&amp;lt;/mainClass&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;executions&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;execution&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;goals&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;goal&amp;gt;&lt;/span&gt;repackage&lt;span class="nt"&gt;&amp;lt;/goal&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/goals&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;configuration&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;classifier&amp;gt;&lt;/span&gt;exec&lt;span class="nt"&gt;&amp;lt;/classifier&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/configuration&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/execution&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/executions&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plugin&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;exec&lt;/code&gt; classifier matters more than it looks. Without it, the repackaged fat jar replaces the plain one, and anything that wanted the plain jar on a classpath no longer has it. With it, both exist: &lt;code&gt;application-1.0.0-SNAPSHOT.jar&lt;/code&gt; for the build, &lt;code&gt;application-1.0.0-SNAPSHOT-exec.jar&lt;/code&gt; for running. The Dockerfile that deploys this application copies the second one.&lt;/p&gt;

&lt;p&gt;What the plugin deliberately does not carry is a profile. It is tempting to put &lt;code&gt;&amp;lt;profiles&amp;gt;&amp;lt;profile&amp;gt;dev&amp;lt;/profile&amp;gt;&amp;lt;/profiles&amp;gt;&lt;/code&gt; there so &lt;code&gt;mvn spring-boot:run&lt;/code&gt; works without arguments, and I have done it before. It bakes an environment into the build, which means the same command produces a &lt;code&gt;dev&lt;/code&gt; application on a laptop and a &lt;code&gt;dev&lt;/code&gt; application on CI, where nothing about &lt;code&gt;dev&lt;/code&gt; is true. The profile belongs at startup, passed in by whoever starts it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The entry point, and the component scan that scans nothing
&lt;/h2&gt;

&lt;p&gt;Here is the whole application class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootApplication&lt;/span&gt;
&lt;span class="nd"&gt;@ComponentScan&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;excludeFilters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;@ComponentScan&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FilterType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REGEX&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com\\.pe\\.advanced\\.spring\\..*"&lt;/span&gt;
&lt;span class="o"&gt;))&lt;/span&gt;
&lt;span class="nd"&gt;@Import&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt;
        &lt;span class="nc"&gt;FlywayConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;PersistenceConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;ServiceWorkersConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;
&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AdvancedApplication&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;SpringApplication&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="nc"&gt;AdvancedApplication&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="n"&gt;args&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 &lt;code&gt;excludeFilters&lt;/code&gt; line is the one that needs explaining, because on first reading it looks like it cancels the thing directly above it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@SpringBootApplication&lt;/code&gt; turns on component scanning from the package of the annotated class downwards. That would find &lt;code&gt;PersistenceConfiguration&lt;/code&gt;, &lt;code&gt;FlywayConfiguration&lt;/code&gt; and the rest, since they live in &lt;code&gt;com.pe.advanced.spring.config&lt;/code&gt;, and register them automatically. The regex filter excludes exactly that subtree, so the scan finds nothing, and every configuration class comes in through the explicit &lt;code&gt;@Import&lt;/code&gt; list instead.&lt;/p&gt;

&lt;p&gt;The result is a list of three class names that is the complete, ordered inventory of what this application is made of. Add a configuration and it does not take effect until the name appears here. Remove one and the context loses it. Nothing joins the application because it happened to be annotated and happened to sit in a scanned package. The list is also the measure of how much of this application exists: three entries, and all three of them are in this chapter.&lt;/p&gt;

&lt;p&gt;That is a trade. Component scanning exists to save exactly this typing, and in a single-module application it saves a great deal of it. Here the typing is the point, and a reader who opens this file sees the current shape of the system rather than having to infer it. It also means a configuration class can be written and committed before it is wired in, which is occasionally useful and never confusing.&lt;/p&gt;

&lt;p&gt;But the stronger reason is one I learned the hard way. A component scan covers a package, and a package is not a module. The scanner walks the classpath, which is flat, so a scan rooted at &lt;code&gt;com.pe.advanced&lt;/code&gt; finds every class in that package in every jar on the classpath, not just the ones in this repository. Share a base package across independently released artifacts, which every organisation with an internal shared library eventually does, and a dependency can contribute beans to this application without anybody choosing to let it.&lt;/p&gt;

&lt;p&gt;The failure that produces is unusually unpleasant, because nothing breaks. The context starts, the tests pass, and the application is subtly wrong: a bean I did not write satisfying an injection point I did write, or a &lt;code&gt;BeanPostProcessor&lt;/code&gt; from a library quietly wrapping things, or my own bean losing a conditional contest to one I had never heard of. Nothing in the repository mentions the class, so reading the source cannot find it, and the search eventually ends at a list of bean definition names and a long moment of not believing what is in it. Worse, it can arrive on a version bump: the dependency gains a &lt;code&gt;@Configuration&lt;/code&gt; in a patch release, and the breaking change is in a jar nobody edited.&lt;/p&gt;

&lt;p&gt;Boot's own autoconfiguration is a different thing and worth not confusing with this. It arrives through a declared file of imports, it backs off when a bean already exists, and it is documented and overridable. The accidental pickup has none of that discipline, because nobody intended it. With the scan excluded and an explicit &lt;code&gt;@Import&lt;/code&gt; list, a dependency's configuration can only enter this context if someone writes its name on that list, which is a sentence a reviewer can disagree with.&lt;/p&gt;




&lt;h2&gt;
  
  
  Beans, declared by hand
&lt;/h2&gt;

&lt;p&gt;With the scan disabled, nothing registers itself. Every bean is a method.&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;ServiceWorkersConfiguration&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ProductsService&lt;/span&gt; &lt;span class="nf"&gt;productsService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProductDao&lt;/span&gt; &lt;span class="n"&gt;productDao&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CategoryAssignmentDao&lt;/span&gt; &lt;span class="n"&gt;assignmentDao&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProductsServiceImpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productDao&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assignmentDao&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CategoriesService&lt;/span&gt; &lt;span class="nf"&gt;categoriesService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CategoryDao&lt;/span&gt; &lt;span class="n"&gt;categoryDao&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CategoryAssignmentDao&lt;/span&gt; &lt;span class="n"&gt;assignmentDao&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CategoriesServiceImpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;categoryDao&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assignmentDao&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CategoryAssignmentsService&lt;/span&gt; &lt;span class="nf"&gt;categoryAssignmentsService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;CategoryAssignmentDao&lt;/span&gt; &lt;span class="n"&gt;assignmentDao&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;CategoryDao&lt;/span&gt; &lt;span class="n"&gt;categoryDao&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;ProductDao&lt;/span&gt; &lt;span class="n"&gt;productDao&lt;/span&gt;
    &lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CategoryAssignmentsServiceImpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignmentDao&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;categoryDao&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;productDao&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 class itself carries no &lt;code&gt;@Configuration&lt;/code&gt;. It does not need it: &lt;code&gt;@Import&lt;/code&gt; registers it as a configuration class, and the &lt;code&gt;@Bean&lt;/code&gt; methods are proxied the same way. Dropping the annotation is a small thing, but it keeps the file from implying it could be picked up on its own.&lt;/p&gt;

&lt;p&gt;What this costs is three lines per service instead of one annotation. What it buys is that &lt;code&gt;ProductsServiceImpl&lt;/code&gt; stays a class anyone can instantiate. &lt;code&gt;ProductsServiceDeleteTestCase&lt;/code&gt; does exactly that: &lt;code&gt;new ProductsServiceImpl(dao, assignmentDao)&lt;/code&gt;, no context, no annotations, no test slice. The moment the class carries &lt;code&gt;@Service&lt;/code&gt;, the question of how to get one in a test stops having an obvious answer, and the twelve chapters spent keeping the business logic free of the container would have bought nothing.&lt;/p&gt;

&lt;p&gt;The same applies to the DAO layer, with one addition. &lt;code&gt;PersistenceConfiguration&lt;/code&gt; declares the DAO beans, and also tells Spring where the entities and repositories live:&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;@EnableJpaRepositories&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;basePackages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.pe.advanced.dao.impl"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;entityManagerFactoryRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"entityManagerFactory"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@EntityScan&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePackages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.pe.advanced.dao.impl"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PersistenceConfiguration&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nd"&gt;@DependsOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"flyway"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AbstractEntityManagerFactoryBean&lt;/span&gt; &lt;span class="nf"&gt;entityManagerFactory&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HikariDataSource&lt;/span&gt; &lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;properties&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;Properties&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JdbcSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DIALECT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MySQLDialect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JdbcSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SHOW_SQL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BatchSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STATEMENT_BATCH_SIZE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"50"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;em&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;LocalContainerEntityManagerFactoryBean&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDataSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setJpaVendorAdapter&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;HibernateJpaVendorAdapter&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setPackagesToScan&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.pe.advanced.dao.impl"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setJpaProperties&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&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;em&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ProductDao&lt;/span&gt; &lt;span class="nf"&gt;productDao&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProductRepository&lt;/span&gt; &lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Validator&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProductDaoImpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CategoryDao&lt;/span&gt; &lt;span class="nf"&gt;categoryDao&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CategoryRepository&lt;/span&gt; &lt;span class="n"&gt;categoryRepository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Validator&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CategoryDaoImpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;categoryRepository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CategoryAssignmentDao&lt;/span&gt; &lt;span class="nf"&gt;categoryAssignmentDao&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CategoryAssignmentRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CategoryAssignmentDaoImpl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@EntityScan&lt;/code&gt; and &lt;code&gt;@EnableJpaRepositories&lt;/code&gt; both point at a package in another module. That is the whole trick, and it is worth saying plainly: the scanning configuration lives in the module doing the scanning, not in the module being scanned. &lt;code&gt;dao-impl&lt;/code&gt; contains entities and repository interfaces and has no idea that anything is looking for them. Move the application elsewhere and &lt;code&gt;dao-impl&lt;/code&gt; does not change.&lt;/p&gt;

&lt;p&gt;The batch size is the loose end from Chapter 12. That chapter deferred &lt;code&gt;hibernate.jdbc.batch_size&lt;/code&gt; with the words "belongs in the application configuration once that module gets one", and this is the module. Fifty is a starting number, not a measured one: enough that a two-hundred-product assignment becomes four round trips instead of two hundred, small enough that a single statement does not grow unreasonable.&lt;/p&gt;

&lt;p&gt;The constants are worth a sentence of their own, because Hibernate moved them. &lt;code&gt;AvailableSettings&lt;/code&gt; used to be one interface holding every property name in the framework, and since 6.2 it has been split into grouped interfaces that it now extends, which means &lt;code&gt;AvailableSettings&lt;/code&gt; still resolves everything while the groups say where a setting belongs. &lt;code&gt;DIALECT&lt;/code&gt; and &lt;code&gt;SHOW_SQL&lt;/code&gt; live on &lt;code&gt;JdbcSettings&lt;/code&gt;, and the batch size does not: it is on &lt;code&gt;BatchSettings&lt;/code&gt;, alongside &lt;code&gt;ORDER_INSERTS&lt;/code&gt; and &lt;code&gt;ORDER_UPDATES&lt;/code&gt;. Reaching for &lt;code&gt;JdbcSettings.STATEMENT_BATCH_SIZE&lt;/code&gt; because the property string begins with &lt;code&gt;hibernate.jdbc&lt;/code&gt; is the obvious mistake, and it is a compile error rather than a silent one, which is the argument for using the constants instead of string literals in the first place.&lt;/p&gt;

&lt;p&gt;Building the &lt;code&gt;EntityManagerFactory&lt;/code&gt; by hand rather than letting Boot autoconfigure it deserves a note, because Boot would do this correctly without being asked. The reason is &lt;code&gt;@DependsOn("flyway")&lt;/code&gt;. Hibernate needs the schema to exist before it validates against it, and bean creation order is the only thing standing between a clean start and a validation error on a fresh database. Declaring the factory explicitly is the simplest place to express that dependency.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migrations that cannot be rewritten
&lt;/h2&gt;

&lt;p&gt;Flyway gets its own configuration class, and almost every line in it is a decision:&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;FlywayConfiguration&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ClassicConfiguration&lt;/span&gt; &lt;span class="nf"&gt;flywayConfig&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HikariDataSource&lt;/span&gt; &lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;config&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;ClassicConfiguration&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDataSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setLocationsAsStrings&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"classpath:db/migrations"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setConnectRetries&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MAX_VALUE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setConnectRetriesInterval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCleanDisabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setOutOfOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setValidateOnMigrate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;initMethod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"migrate"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Flyway&lt;/span&gt; &lt;span class="nf"&gt;flyway&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ClassicConfiguration&lt;/span&gt; &lt;span class="n"&gt;flywayConfig&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Flyway&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flywayConfig&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setCleanDisabled(true)&lt;/code&gt; is the line that matters most. Flyway's clean operation drops every object in the schema. It is a wonderful tool on a local database and a catastrophe anywhere else, and the distance between those two situations is one wrong value in one environment variable. It is off permanently, and the only way to clean a database in this project is to do it yourself, on purpose, outside the application.&lt;/p&gt;

&lt;p&gt;There used to be a second line here, and the story of why it is gone is worth a short detour. Flyway once had a clean-on-validation-error option, which did exactly what it says: on finding that the applied migrations no longer matched the files, it responded by dropping the schema. The reasoning was that a developer with a broken local database wants it rebuilt, and the reasoning is fine right up to the first time it runs somewhere that is not a developer's laptop. I had written &lt;code&gt;setCleanOnValidationError(false)&lt;/code&gt; in this class, and on moving to a current Spring Boot the method stopped existing, because Flyway deprecated it in 10.17 and removed it in 11. That is the rare and pleasant case of a library agreeing with you by deletion: the safe position stopped being something you have to remember to configure and became the only behaviour available. The version of this class that compiles today is shorter than the one I started with and says exactly the same thing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setValidateOnMigrate(true)&lt;/code&gt; compares the checksum of every applied migration against the file on the classpath. Edit a migration that has already run and the application refuses to start. This is the behaviour people complain about until the first time it catches a migration that was changed after it had been applied to staging.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setOutOfOrder(false)&lt;/code&gt; refuses a migration with a version lower than the highest applied. Two developers writing &lt;code&gt;V4__&lt;/code&gt; on separate branches find out at merge time rather than when one of them silently never runs.&lt;/p&gt;

&lt;p&gt;The two retry settings are a different kind of decision, and the reason they exist is not that the application and the database start together. They do not, they are separate containers in a compose file and, in production, the database is a separate deployment or a managed instance that has been running for months. The accurate statement is narrower and applies more often: nothing guarantees the database is reachable at the moment this process starts. Locally that is a compose run where MySQL is initialising its data directory while the application is already up. In production it is a rolling restart that lands during a failover, a network policy that has not finished applying, or a pod scheduled a moment before the route to the database exists.&lt;/p&gt;

&lt;p&gt;Without retries, any of those produces the same outcome. The context fails, the process exits, the orchestrator restarts it, and because restart backoff compounds, a database that was forty seconds late turns into several minutes of a pod in &lt;code&gt;CrashLoopBackOff&lt;/code&gt;. Retrying means the process stays up and waits, which is both faster and far easier to read in a log.&lt;/p&gt;

&lt;p&gt;That has a real cost and it’s worth mentioning it. Retrying forever means the application never fails with an exit code anyone can see. It sits there, starting, indefinitely. What makes that acceptable rather than a hang is the readiness probe configured in Part 3: the process reports itself not ready, no traffic is routed to it, and the health endpoint says which component is failing. Without a probe, infinite retries is a hang nobody is measuring, and a startup probe with a deadline is the thing that puts a ceiling on it. The decision to retry forever is really a decision to move the timeout out of Flyway and into the orchestrator, where it belongs, and it is only correct if the orchestrator is actually holding it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setConnectRetriesInterval(10)&lt;/code&gt; is the companion that is easy to miss. The interval is a cap on a backoff rather than a fixed wait, and its default cap is a hundred and twenty seconds, so a database that takes a minute to come up can cost considerably more than a minute of waiting while the interval grows past it. Ten seconds keeps the retries frequent enough that the application starts roughly when the database does, which is the behaviour the local compose run needs and the one a rolling restart benefits from too.&lt;/p&gt;

&lt;p&gt;What this class configures and what it actually runs are two different subjects, and only the first one belongs here. The migration itself turned out to carry enough decisions of its own to need a chapter, so the SQL, the column types and the constraints three earlier chapters have been relying on are the one after this.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Part 1 bought
&lt;/h2&gt;

&lt;p&gt;The application module now has a dependency list that reads as an architecture diagram, an entry point whose &lt;code&gt;@Import&lt;/code&gt; block is the complete inventory of what the context contains, three services and three DAOs declared as methods rather than discovered by annotation, and a Flyway bean that will refuse to start the application rather than guess.&lt;/p&gt;

&lt;p&gt;What it does not have is a single setting. The datasource that &lt;code&gt;PersistenceConfiguration&lt;/code&gt; and &lt;code&gt;FlywayConfiguration&lt;/code&gt; both take as a parameter does not exist yet, because a datasource needs a URL, a username and a password, and none of those three belong in a file that is in git. That is the subject of Part 2: four places a setting can come from, each answering a different question, and the shell script that turns a container's environment into something a JVM will accept.&lt;/p&gt;




&lt;h3&gt;
  
  
  What's Next?
&lt;/h3&gt;

&lt;p&gt;The application has every bean it needs and still cannot start. &lt;code&gt;PersistenceConfiguration&lt;/code&gt; and &lt;code&gt;FlywayConfiguration&lt;/code&gt; both take a &lt;code&gt;HikariDataSource&lt;/code&gt; as a parameter, nothing in the repository declares one, and a datasource needs a URL, a username and a password, none of which can be committed.&lt;/p&gt;

&lt;p&gt;Part 2 gives each of those a home, chosen by what the setting is true of rather than by what is convenient: identical everywhere, true of an environment, true of one machine, true of a container. It also reads the datasource block line by line, which turns out to be the densest file in the project and to contain two settings that were doing nothing at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Codebase &amp;amp; Architecture Blueprint
&lt;/h3&gt;

&lt;p&gt;The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 13, Part 1, use the following link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Repository (Tag: &lt;code&gt;chapter-13-spring-p1&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/KamenIvanov/advanced-spring-multimodule/tree/chapter-13-spring-p1" rel="noopener noreferrer"&gt;advanced-spring-multimodule&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: All core modules are configured with strict compilation-level boundaries. Compile and run &lt;code&gt;mvn clean install&lt;/code&gt; to see the structure in action. Maven version 3.9.* and Java 25 are required.&lt;/p&gt;

&lt;p&gt;◀️ &lt;a href="https://dev.to/kamenivanov/the-domain-type-that-doesnt-fit-many-to-many-links-and-bulk-operations-chapter-12-id"&gt;Read Chapter 12: The Domain Type That Doesn't Fit: Many-to-Many Links and Bulk Operations&lt;/a&gt;&lt;br&gt;
▶️ Read Chapter 13 Part 2: Bootstrapping the System, Where the Settings Come From (Coming soon)&lt;/p&gt;




&lt;h3&gt;
  
  
  Join the Masterclass Journey
&lt;/h3&gt;

&lt;p&gt;This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, &lt;strong&gt;&lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;subscribe to my Substack Newsletter here&lt;/a&gt;&lt;/strong&gt;!&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;Disclosure: I earn a commission if you purchase through the links below, at no additional cost to you.&lt;/p&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code&lt;/em&gt; &lt;strong&gt;&lt;em&gt;friends20&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;for an exclusive&lt;/em&gt; &lt;strong&gt;&lt;em&gt;20% discount&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>java</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>The Domain Type That Doesn't Fit: Many-to-Many Links and Bulk Operations (Chapter 12)</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Tue, 06 Oct 2026 11:30:22 +0000</pubDate>
      <link>https://dev.to/kamenivanov/the-domain-type-that-doesnt-fit-many-to-many-links-and-bulk-operations-chapter-12-id</link>
      <guid>https://dev.to/kamenivanov/the-domain-type-that-doesnt-fit-many-to-many-links-and-bulk-operations-chapter-12-id</guid>
      <description>&lt;p&gt;&lt;code&gt;CategoryAssignment&lt;/code&gt; records that a product belongs to a category, who put it there, and when. Three ids and a timestamp. It is the simplest type in this project, and it is the first one that does not fit anything the previous eleven chapters built.&lt;/p&gt;

&lt;p&gt;Not in one place. In three, at three different layers, for the same reason.&lt;/p&gt;

&lt;p&gt;It cannot extend &lt;code&gt;AbstractCrudService&lt;/code&gt;, because that class implements &lt;code&gt;CrudService&amp;lt;NewDomain, UpdateDomain, Domain&amp;gt;&lt;/code&gt; and there is no update operation to put in the method. An assignment is created or deleted, a changed assignment is a different assignment. It cannot extend &lt;code&gt;CrudDaoImpl&lt;/code&gt; either, which requires a &lt;code&gt;CrudRepository&lt;/code&gt; and implements a &lt;code&gt;CrudDao&lt;/code&gt; carrying &lt;code&gt;update&lt;/code&gt; and &lt;code&gt;delete(Domain)&lt;/code&gt;. And the inherited persistence tests in &lt;code&gt;AbstractCrudTestCase&lt;/code&gt; are bound to &lt;code&gt;CrudDao&lt;/code&gt;, so not one of them applies.&lt;/p&gt;

&lt;p&gt;Three refusals, one cause. The template was built for types with a lifecycle, and this type has no lifecycle - it has an existence.&lt;/p&gt;

&lt;p&gt;That is the principle I keep returning to in this series. Shared behaviour belongs in a base class when the shared ancestry is real, not when two implementations happen to look alike today. Products and categories share a lifecycle, so they share a base class. An assignment shares a creation timestamp with them and nothing else.&lt;/p&gt;

&lt;p&gt;One thing about the domain needs stating before the code, because it decides most of what follows. Categories are platform taxonomy. An administrator creates them, archives them, and nobody else edits them. In a real deployment they would arrive from a Flyway migration or an admin screen and the products belong to the seller who created them. So an assignment links two objects with different owners, which is the clearest case there is for giving it a type of its own rather than a column on one side. A category cannot carry a list of products belonging to other people, and a product cannot own its place in a shared taxonomy.&lt;/p&gt;

&lt;p&gt;Before any of that can be built, though, the type itself needs fixing. The version published back in Chapter 2 had a single constructor taking an id, which left no way to build a domain object that has not been persisted yet. The fields were not final, and by the time I reopened the file for this chapter it had grown a setter for each of the three ids - a type whose entire identity is the pair it links, with a public way to change that pair after the fact. Chapter 2 also called it a "Relationship Entity", and in this series that word now belongs to the JPA class in dao-impl, so I will say domain type from here on.&lt;/p&gt;




&lt;h2&gt;
  
  
  The type, corrected
&lt;/h2&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;CategoryAssignment&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractCreatable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;categoryId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;assignedById&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;CategoryAssignment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;categoryId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;assignedById&lt;/span&gt;
    &lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&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;categoryId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requireNonNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;categoryId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"categoryId is mandatory"&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;productId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requireNonNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"productId is mandatory"&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;assignedById&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requireNonNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignedById&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"assignedById is mandatory"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;CategoryAssignment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;categoryId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;assignedById&lt;/span&gt;
    &lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// same three null checks&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Getters&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first constructor builds a domain object that has not been persisted. &lt;code&gt;AbstractCreatable&lt;/code&gt; leaves its id null and stamps a provisional &lt;code&gt;createdAt&lt;/code&gt;. The stored value is set by the DAO, as every DAO in this project does, truncated to the column's millisecond precision so a returned instance equals a reloaded one. The second one is used by the transformer when it transforms the entity to a domain model. Because the id is final since an earlier chapter, a DAO cannot fill it in afterwards, so it returns a new, persisted domain object and leaves the transient one untouched. Both constructors reject null for all three ids, because with no setters a null could never be repaired later.&lt;/p&gt;

&lt;p&gt;The argument order is deliberate - Category comes first, matching every DAO method and the endpoint path &lt;code&gt;categories/{id}/products&lt;/code&gt;. Three consecutive &lt;code&gt;UUID&lt;/code&gt; parameters still compile when two are swapped, so the domain test constructs each variant with four distinct values and asserts every getter. It is a dull test, and it is the only thing standing between a transposed argument and a row that links the wrong ids.&lt;/p&gt;




&lt;h2&gt;
  
  
  The decision that was really about failure
&lt;/h2&gt;

&lt;p&gt;When I started this chapter, the open question was how to handle transactions when a partial failure is expected. The choices looked like per-item &lt;code&gt;REQUIRES_NEW&lt;/code&gt; against one transaction for the whole batch. I now think that framing was wrong, because the transaction boundary is a consequence of where failures are detected.&lt;/p&gt;

&lt;p&gt;Inside a Hibernate session, a constraint violation leaves the persistence context unusable. That is why per-item &lt;code&gt;REQUIRES_NEW&lt;/code&gt; looks attractive: it is the obvious way to keep going after a database-level failure. It has a cost that is easy to miss, each inner transaction needs its own connection while the outer one still holds the first, so a batch of a few hundred items under load is a direct path to an exhausted connection pool. Savepoints (&lt;code&gt;NESTED&lt;/code&gt;) avoid the second connection, but they still pay one round trip per item and keep the per-item failure handling in the database. But almost every failure this operation can have is discoverable by reading first. An unknown product, a product the requester does not own, an archived product, and a pair that is already assigned all show up in a query before anything is written. The service classifies every submitted id up front, writes only the ones that passed, and runs it all in a single transaction.&lt;/p&gt;

&lt;p&gt;Reading first opens a small gap: between the read and the insert, another request could archive a product, and the classification would be stale. A shared lock on the read would close it, but only until the batch commits, and nothing stops the same archive a millisecond later. The end state is identical, so the lock buys an ordering nobody observes and pays for it with &lt;code&gt;UPDATE&lt;/code&gt;s waiting on a few hundred rows. The version is the same one the category side already gets: a product archived moments after it was classified leaves a link that nothing reads, until something cleans it up.&lt;/p&gt;

&lt;p&gt;The one failure left is a race condition, where another request assigns the same pair between the read and the write. A unique constraint on &lt;code&gt;(category_id, product_id)&lt;/code&gt; catches it, the flush fails, the whole batch rolls back, and the &lt;code&gt;DataIntegrityViolationException&lt;/code&gt; becomes the 409 that Chapter 11's handler already produces. No new exception type and no &lt;code&gt;REQUIRES_NEW&lt;/code&gt;. The DAO flushes at the end of &lt;code&gt;saveAll&lt;/code&gt;, so the race fails at a known place that a test can reach, not at commit time somewhere outside the DAO.&lt;/p&gt;

&lt;p&gt;This design also shaped the SQL. The repository I started from used &lt;code&gt;unnest&lt;/code&gt; and &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;, which are PostgreSQL features, and this project runs on MySQL. The MySQL equivalent, &lt;code&gt;INSERT IGNORE&lt;/code&gt;, is worse than it looks, because it also swallows truncation and not-null errors that have nothing to do with duplicates. The fast H2 tests would not run either statement. Reading first and writing only what is new needs nothing but plain JPQL, which behaves the same on both databases, and that is why the persistence tests stay representative.&lt;/p&gt;




&lt;h2&gt;
  
  
  One result per submitted id
&lt;/h2&gt;

&lt;p&gt;The response contains every product the client submitted, in the order it submitted them. I model it as a sealed type with one record per outcome.&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;ProductAssignmentResult&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Assigned&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CategoryAssignment&lt;/span&gt; &lt;span class="n"&gt;assignment&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;ProductAssignmentResult&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;AlreadyAssigned&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;productId&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;ProductAssignmentResult&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;ProductNotFound&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;productId&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;ProductAssignmentResult&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;ProductArchived&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;productId&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;ProductAssignmentResult&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;I considered returning the exception that would have been thrown for each failed item, which keeps one source of truth for what "not found" means. I decided against it, because it constructs exceptions that are never thrown, each one capturing a stack trace, and because the web layer would then need the status mapping extracted out of &lt;code&gt;GeneralExceptionHandler&lt;/code&gt;. A sealed type is also exhaustive in the controller's switch, so adding a fifth outcome later is a compile error until every consumer handles it.&lt;/p&gt;

&lt;p&gt;An earlier version had &lt;code&gt;ProductNotOwned&lt;/code&gt; also, and it answered a question nobody should be able to ask: submit any UUID and the response tells you whether a product with that id exists in someone else's account. Since categories are shared, other people's products genuinely are in there, which makes the leak a real one rather than a theoretical one. A product the requester does not own is reported as &lt;code&gt;ProductNotFound&lt;/code&gt;, the same way a single &lt;code&gt;GET&lt;/code&gt; on a foreign resource would answer. From the caller's side, a product it cannot see does not exist. &lt;code&gt;ProductArchived&lt;/code&gt; does not reopen the gap, because it is only ever returned for products the requester owns.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ProductArchived&lt;/code&gt; is the one that nearly got missed. An archived category rejects the whole request, which was obvious enough, but an archived product is a different shape of the same rule. &lt;code&gt;ARCHIVED&lt;/code&gt; is terminal for products: &lt;code&gt;canTransitionTo&lt;/code&gt; returns false for everything, so the product will never be visible again, and a link to it is dead the moment it is written, so blocking it is the right thing to do. Failing the entire batch over is not, because the archived product is one item among many that may be perfectly valid. That is the line the whole design runs on, and it is worth stating plainly: a problem with the request fails the request, a problem with one product becomes one result.&lt;/p&gt;

&lt;p&gt;A category that does not exist fails the entire request with the &lt;code&gt;NotFoundException&lt;/code&gt; that we already have. An archived category fails it with a &lt;code&gt;ConflictException&lt;/code&gt;, since archived means closed to new content - the platform has closed that branch of the taxonomy, and no seller may add to it. Those say something about the request as a whole. Everything about a single product belongs in the result. An already-assigned product counts as a success, mirroring the idempotent &lt;code&gt;delete()&lt;/code&gt; from earlier chapters, so a client can resend a half-failed batch and converge. Duplicate ids in a request are reported once, at the position of their first occurrence.&lt;/p&gt;

&lt;p&gt;The order of the checks matters for one case. An archived product that is also already assigned is reported as &lt;code&gt;ProductArchived&lt;/code&gt;, not &lt;code&gt;AlreadyAssigned&lt;/code&gt;, because the archive is what blocked it and the existing assignment is incidental. Archived products never reach the already-assigned query at all, which a test asserts directly.&lt;/p&gt;

&lt;p&gt;The REST chapter will expose this as 207 Multi-Status, with one status per product, so a client never has to infer partial success from a 200. That mapping lives in the controller. The service result carries no HTTP status, because business logic should not know about the web. The design assumes a bounded batch, a single transaction and an &lt;code&gt;IN&lt;/code&gt; clause are fine at a few hundred ids and a problem at a hundred thousand, so the request body needs a size cap, enforced as a Bean Validation constraint at the web boundary. The same bound matters for the insert. Without &lt;code&gt;hibernate.jdbc.batch_size&lt;/code&gt;, &lt;code&gt;saveAll&lt;/code&gt; sends one &lt;code&gt;INSERT&lt;/code&gt; per row. The ids come from &lt;code&gt;@UuidGenerator&lt;/code&gt; with version 7, assigned in memory before the statement runs, which is what makes batching possible at all: with an &lt;code&gt;IDENTITY&lt;/code&gt; column the database assigns the key during the insert, so Hibernate has to execute each statement immediately and silently disables batching no matter what the setting says. The setting belongs in the application configuration once that module gets one.&lt;/p&gt;




&lt;h2&gt;
  
  
  One query, two facts
&lt;/h2&gt;

&lt;p&gt;Classifying a product needs two things about it: who created it, and whether it is archived. An earlier version of this chapter had a &lt;code&gt;findCreatorIds&lt;/code&gt; method returning &lt;code&gt;Map&amp;lt;UUID, UUID&amp;gt;&lt;/code&gt;, which answered the first question only. Adding the archive check could have meant a second query for the status, or loading whole products and throwing most of each one away.&lt;/p&gt;

&lt;p&gt;Instead the method widened into &lt;code&gt;findAssignmentInfo&lt;/code&gt;, returning a small record:&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;ProductAssignmentInfo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;createdById&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProductStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The projection in &lt;code&gt;ProductRepository&lt;/code&gt; selects a third column and nothing else, so the cost is the same single query it always was. The DAO translates &lt;code&gt;ProductStatusEntity&lt;/code&gt; to the domain &lt;code&gt;ProductStatus&lt;/code&gt; on the way out, the way every other enum crosses that boundary in this project. It is a narrow method shaped for exactly one caller, which is usually a smell, but here it is the point. A general-purpose &lt;code&gt;loadAll(Collection&amp;lt;UUID&amp;gt;)&lt;/code&gt; would hydrate specifications, prices and names to answer two questions, on a path that may be classifying several hundred ids at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  Unassigning, and what deleting a parent means
&lt;/h2&gt;

&lt;p&gt;Only assignment is a bulk operation. Removing a product from a category is a single DELETE on one pair, and it needs no per-item result. A missing pair is a quiet no-op, so the endpoint answers 204 either way. The category must exist, so a client mistake still produces a 404, but ownership is checked on the product, not the category: the taxonomy is shared, and what a seller may remove is their own product's place in it. The category may be archived, because blocking removal would trap products in a branch nobody can edit. An archived product can be unassigned too, for the same reason - the rule is about adding dead links, not keeping them.&lt;/p&gt;

&lt;p&gt;Deleting a product or a category must also delete its assignments, and I wanted that rule written in Java, not hidden in a migration. The two overridden delete methods in &lt;code&gt;ProductsServiceImpl&lt;/code&gt; and &lt;code&gt;CategoriesServiceImpl&lt;/code&gt; call &lt;code&gt;super.delete&lt;/code&gt; first and then clear the assignments through the DAO. The order matters, because an unauthorized caller gets its exception before anything is touched. A missing parent still triggers the cleanup, so a retried delete heals an orphan. The override carries its own &lt;code&gt;@Transactional&lt;/code&gt;. Spring would find the one on the superclass method anyway, but the composite operation is defined here, and its transaction boundary should be visible in the same place.&lt;/p&gt;

&lt;p&gt;On the product side, I also kept a foreign key with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; in the future Flyway script, and I want to be plain about what that does. It is a backstop for one race: a product deleted between the existence read and the insert makes the insert fail, which becomes the same 409 as a duplicate. It is not the mechanism. The Java cleanup is the mechanism, and the H2 tests prove it. In production, MySQL cascades first, which would mask a regression in the Java cleanup, so those tests are the only thing that would catch one. The category side has no foreign key. Category deletion may one day arrive as an event from another service, and a constraint into another database cannot exist anyway. The consequence is an orphan window: a category deleted mid-batch can leave a dangling link that nothing reads, until a retried delete clears it. The design is not airtight, and I would rather state that than imply it.&lt;/p&gt;

&lt;p&gt;The assignment feature also touches neither &lt;code&gt;ProductEntity&lt;/code&gt; nor &lt;code&gt;CategoryEntity&lt;/code&gt;, which hold no associations to it. If you copy this project as a template and do not need categories, you can delete the assignment feature without editing either of them. The one leftover is &lt;code&gt;findAssignmentInfo&lt;/code&gt; on &lt;code&gt;ProductDao&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tests that found two bugs
&lt;/h2&gt;

&lt;p&gt;The category search specification had a bug that no test could see. When the boolean &lt;code&gt;active&lt;/code&gt; flag became a status enum, the specification kept calling &lt;code&gt;root.get("active")&lt;/code&gt; and &lt;code&gt;CategorySort&lt;/code&gt; kept an &lt;code&gt;ACTIVE("active")&lt;/code&gt; key. Everything compiled, the service tests passed, because the DAO is mocked there. &lt;code&gt;AbstractSearchableTestCase&lt;/code&gt; never sets a filter, so the specification was never reached. Only a test that runs a real query against H2 can see this kind of failure, because the property name is a string that no compiler checks. The fix was to replace the boolean filter with a &lt;code&gt;CategoryStatus&lt;/code&gt; and to rename the sort key. The lasting part is the test, a parameterised test over every &lt;code&gt;CategorySort&lt;/code&gt; value, and another over every &lt;code&gt;CategoryStatus&lt;/code&gt;, means a future enum value is covered without anyone remembering to add a test. Those tests would have caught this bug the day it was introduced.&lt;/p&gt;

&lt;p&gt;Writing them exposed a second bug in the shared &lt;code&gt;SearchableDaoImpl&lt;/code&gt;. A caller that sets a sort key and leaves the direction null reaches a switch on a null enum, which throws a &lt;code&gt;NullPointerException&lt;/code&gt; before any branch runs, even though a comment claimed the default branch handled it. One line fixes it, &lt;code&gt;case null, default -&amp;gt; Sort.Direction.DESC&lt;/code&gt;, and a test asserting the resulting order guards it. Both bugs fail inside the data access layer, which is exactly where mocked service tests never look.&lt;/p&gt;




&lt;h2&gt;
  
  
  What these tests cannot prove
&lt;/h2&gt;

&lt;p&gt;Three limits are worth knowing about. &lt;code&gt;@DataJpaTest&lt;/code&gt; wraps every test in a transaction, which silently satisfies the &lt;code&gt;@Modifying&lt;/code&gt; delete's need for one, so none of these tests would catch a service that forgets to open a transaction. The duplicate-pair test must be the last statement in its method, because a failed flush poisons the session, and it cannot show that a whole batch rolled back. And the foreign key never appears in H2 at all, because the schema there comes from Hibernate and the entity holds plain UUID columns. All three are a future chapter's job, against a real MySQL.&lt;/p&gt;




&lt;h2&gt;
  
  
  One simplification, stated up front
&lt;/h2&gt;

&lt;p&gt;There is no role model yet, so the check that should ask whether the caller may curate categories asks whether they created one instead - &lt;code&gt;CategoriesServiceImpl.authorize&lt;/code&gt; compares the requester with the creator, which stands in for a role that does not exist. The service assumes the caller reached it legitimately, and &lt;code&gt;CategoryAssignmentsService&lt;/code&gt; never asks who owns a category because the answer is always the platform. When identity arrives from IDP later in the series, the category write operations gain a role check and this service does not change at all. That is the shape you want from the boundary: the rule that moves is the one about who may call, not the one about what the call does.&lt;/p&gt;




&lt;h3&gt;
  
  
  What's Next?
&lt;/h3&gt;

&lt;p&gt;Everything in this chapter is a plain class with a constructor. Nothing declares a bean, nothing scans for entities, and the only place the pieces are wired together is a test. A REST layer needs an application to live in first.&lt;/p&gt;

&lt;p&gt;Chapter 13 assembles one, in the application module and nowhere else. Every bean comes from a factory method there, because an &lt;code&gt;@Service&lt;/code&gt; on &lt;code&gt;CategoryAssignmentsServiceImpl&lt;/code&gt; would make the business logic module a Spring module, and the eleven chapters spent keeping it free of the container would have bought nothing. The same question returns for &lt;code&gt;@EntityScan&lt;/code&gt; and &lt;code&gt;@EnableJpaRepositories&lt;/code&gt;, which have to reach packages in another module without that module knowing it is being scanned. And &lt;code&gt;hibernate.jdbc.batch_size&lt;/code&gt;, deferred twice in this chapter, finally has somewhere to go.&lt;/p&gt;

&lt;h3&gt;
  
  
  Codebase &amp;amp; Architecture Blueprint
&lt;/h3&gt;

&lt;p&gt;The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 12, use the following link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Repository (Tag: &lt;code&gt;chapter-12-bulk-ops&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/KamenIvanov/advanced-spring-multimodule/tree/chapter-12-bulk-ops" rel="noopener noreferrer"&gt;advanced-spring-multimodule&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: All core modules are configured with strict compilation-level boundaries. Compile and run &lt;code&gt;mvn clean install&lt;/code&gt; to see the structure in action. Maven version 3.9.* and Java 25 are required.&lt;/p&gt;

&lt;p&gt;◀️ &lt;a href="https://dev.to/kamenivanov/error-handling-domain-exceptions-across-module-boundaries-chapter-11-1179"&gt;Read Chapter 11: Error Handling &amp;amp; Domain Exceptions Across Module Boundaries&lt;/a&gt;&lt;br&gt;
▶️ Read Chapter 13 (Coming soon)&lt;/p&gt;




&lt;h3&gt;
  
  
  Join the Masterclass Journey
&lt;/h3&gt;

&lt;p&gt;This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, &lt;strong&gt;&lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;subscribe to my Substack Newsletter here&lt;/a&gt;&lt;/strong&gt;!&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;Disclosure: I earn a commission if you purchase through the links below, at no additional cost to you.&lt;/p&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code&lt;/em&gt; &lt;strong&gt;&lt;em&gt;friends20&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;for an exclusive&lt;/em&gt; &lt;strong&gt;&lt;em&gt;20% discount&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>One missing index turned a 40ms query into a 12-second one</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Sat, 03 Oct 2026 07:05:54 +0000</pubDate>
      <link>https://dev.to/kamenivanov/one-missing-index-turned-a-40ms-query-into-a-12-second-one-70l</link>
      <guid>https://dev.to/kamenivanov/one-missing-index-turned-a-40ms-query-into-a-12-second-one-70l</guid>
      <description>&lt;p&gt;The query had run fine for two years. It joined orders to customers on a foreign key, filtered by date range, and came back in under 40 milliseconds every time anyone checked it, which nobody had reason to do, because it wasn't slow. Then a support ticket came in about a report timing out, and the same query, unchanged, was taking 12 seconds.&lt;/p&gt;

&lt;p&gt;Nothing about the query had changed. What had changed was the table it joined against, which had grown from a few hundred thousand rows to just over 2 million over those two years, gradually enough that no single deploy looked like the moment it broke. The foreign key column on the customer side had never had an index. It didn't need one at 200,000 rows, a full scan of a table that size is fast enough that the missing index costs nothing anyone would notice. At 2 million rows, the same scan is a different kind of operation entirely, and the query planner had no faster path to fall back on because there wasn't one to fall back to.&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%2Fml70d7di8uq9fndxoh41.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%2Fml70d7di8uq9fndxoh41.png" alt="Query latency" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The part that made this take longer to diagnose than it should have was that the execution plan looked reasonable at a glance, a join, a filter, nothing exotic. It was only clear once someone ran &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; and saw a sequential scan against the customer table where an index scan should have been, at which point the missing index became obvious in a way it never had been from reading the schema itself. A missing index doesn't announce itself in code review, it's the absence of something, and absences don't get flagged unless someone is specifically looking for them. Adding the index took a few minutes and, on a table that size, a maintenance window to build without locking writes for the duration. Query time dropped back to single-digit milliseconds immediately afterward. The fix wasn't clever, it was the most standard advice in relational database work, index your foreign keys, and it still took a production incident to surface a gap that had been sitting there since the table was created.&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%2F7mb909n25a4lt4t8xumt.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%2F7mb909n25a4lt4t8xumt.png" alt="Index before and after" width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Table growth is slow and continuous, which is exactly why this kind of gap survives so long. Nothing about a 2-million-row table looks alarming from the outside, and a query that's always been fast doesn't get re-audited just because the data underneath it kept growing. If you have foreign keys on tables that started small and never got a second look, it's worth checking now, before the row count picks the moment for you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More like this?&lt;/strong&gt;&lt;br&gt;
I write short production war stories like this one, plus a deep-dive architecture series. &lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;Subscribe here&lt;/a&gt; if you want both in your inbox.&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code&lt;/em&gt; &lt;strong&gt;&lt;em&gt;friends20&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;for an exclusive&lt;/em&gt; &lt;strong&gt;&lt;em&gt;20% discount&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>sql</category>
    </item>
    <item>
      <title>One bad payload took down our kafka consumer group for 45 Minutes</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:26:06 +0000</pubDate>
      <link>https://dev.to/kamenivanov/one-bad-payload-took-down-our-kafka-consumer-group-for-45-minutes-h</link>
      <guid>https://dev.to/kamenivanov/one-bad-payload-took-down-our-kafka-consumer-group-for-45-minutes-h</guid>
      <description>&lt;p&gt;The alert fired in the afternoon: consumer lag climbing on a topic that normally sits close to zero, not a spike - a wall. Messages were arriving, nothing was being processed, and the lag graph looked like a cliff instead of a curve.&lt;/p&gt;

&lt;p&gt;The first assumption was a downstream dependency, the usual suspect for this kind of stall, a slow database call or an unresponsive third-party API holding up the consumer thread, but none of that was the reason. The consumer wasn't slow, it was crashing, over and over, on the same message, at the same offset, every time it restarted.&lt;/p&gt;

&lt;p&gt;The payload was a JSON event from an upstream service, a schema change that had gone out that morning, one new field added to a nested object. Nothing about the change looked breaking. It wasn't breaking for the producer, and it wasn't breaking for most consumers of that topic. It was breaking for this one, because the deserializer was configured to fail closed on an unrecognized field instead of ignoring it, a setting picked long ago for a reason nobody currently on the team remembered. &lt;/p&gt;

&lt;p&gt;Every time the consumer restarted, it picked up from the last committed offset, hit the same malformed-for-us message, threw a deserialization exception, and died before committing anything past it, there was no dead letter queue. There was no path around the message. The consumer group was structurally unable to make progress past that offset, and every restart just replayed the same failure in a loop, which is why 45 minutes passed before someone manually skipped the offset in production, a fix that felt more like surgery than an incident response.&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%2F7hjx58zbhk8fsyb8spnk.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%2F7hjx58zbhk8fsyb8spnk.png" alt="DLQ" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What made this expensive wasn't the schema change itself, changes like that happen constantly and usually pass through without anyone noticing. What made it expensive was that the failure mode had no drain. A single unreadable message had the same blast radius as an outage, because the only two options the consumer had were process it or die trying. There was no third option that said set it aside and keep going. The fix wasn't the deserializer setting, though that got revisited too, it was giving the consumer a way to fail a single message without failing the group: a dead letter queue wired into the exception handler, so a poison message gets published somewhere inspectable, the offset commits, and the consumer moves on. The failure becomes a queue with one message in it instead of a stalled pipeline and a page at 2 in the morning.&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%2Fc8zcjsc88zu9qipwqnx5.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%2Fc8zcjsc88zu9qipwqnx5.png" alt="Kafka Consumer" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your consumers don't have a DLQ, the question worth asking isn't whether a malformed message will show up, it's what happens to the entire group the day it does. A queue with no drain for its own failures isn't resilient, it's just quiet until the first message it can't handle.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More like this?&lt;/strong&gt;&lt;br&gt;
I write short production war stories like this one, plus a deep-dive architecture series. &lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;Subscribe here&lt;/a&gt; if you want both in your inbox.&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code&lt;/em&gt; &lt;strong&gt;&lt;em&gt;friends20&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;for an exclusive&lt;/em&gt; &lt;strong&gt;&lt;em&gt;20% discount&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>backend</category>
      <category>devops</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Error Handling &amp; Domain Exceptions Across Module Boundaries (Chapter 11)</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Mon, 28 Sep 2026 06:09:53 +0000</pubDate>
      <link>https://dev.to/kamenivanov/error-handling-domain-exceptions-across-module-boundaries-chapter-11-1179</link>
      <guid>https://dev.to/kamenivanov/error-handling-domain-exceptions-across-module-boundaries-chapter-11-1179</guid>
      <description>&lt;p&gt;I had a &lt;code&gt;GeneralExceptionHandler&lt;/code&gt; sitting in an old project, battle-tested, comprehensive, ready to copy into this one. Six exception types, each with its own handler method, each mapped to a status code. Dropping it in would have taken five minutes. But I didn't do it, because a question stopped me before I did: how many of those six exceptions does this codebase actually throw.&lt;/p&gt;

&lt;p&gt;The answer was two - &lt;code&gt;AuthorizationException&lt;/code&gt;, from &lt;code&gt;authorize()&lt;/code&gt;, when a requester who isn’t the owner tries to touch a &lt;code&gt;Product&lt;/code&gt; or &lt;code&gt;Category&lt;/code&gt; they don’t control and &lt;code&gt;NotFoundException&lt;/code&gt; from &lt;code&gt;loadOrThrowNotFound()&lt;/code&gt;, when an id doesn’t resolve to anything. Everything else in that inherited handler - &lt;code&gt;ForbiddenException&lt;/code&gt;, &lt;code&gt;VerificationException&lt;/code&gt;, &lt;code&gt;InvalidSessionException&lt;/code&gt; belonged to a different system, with a different session model, different verification flow, different reasons a client might be denied. Copying the handler wholesale would have meant importing four exception types with no call site anywhere in this codebase, sitting in the code as pure potential energy, waiting for a feature that may or may not ever arrive in the shape that would make them relevant.&lt;/p&gt;

&lt;p&gt;This chapter builds the exception handling for this service the other way around: starting from what the business logic actually throws, and adding only the machinery that translation requires. Nothing pre-built for a feature that isn't there yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  What already exists, once you look
&lt;/h2&gt;

&lt;p&gt;Two of the three failures this chapter deals with aren't new. They're already in the business logic, already covered by the test suite from the last two chapters - the only thing missing is the translation layer that turns them into something an HTTP client can act on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthorizationException&lt;/code&gt; is a known quantity by now:&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;protected&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;requesterId&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;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedById&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;requesterId&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;AuthorizationException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UNAUTHORIZED&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 requester is known but they’re just not allowed to do what they asked. That's a 403 - not a 401, which means something narrower and different: the server doesn't know who's asking at all, or the credentials it received don't hold up. The two are easy to blur together in casual conversation, less easy to justify blurring in a status code a client is expected to branch on. A client that gets a 401 usually tries to re-authenticate. A client that gets a 403 usually doesn't, re-authenticating as the same user won't change who owns the resource. Sending the wrong one back doesn't just misname the failure, it tells the client to attempt the wrong recovery.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IllegalStateException&lt;/code&gt; is the second one, and it isn't a custom type at all, it's what &lt;code&gt;Product.transitionTo()&lt;/code&gt; and &lt;code&gt;Category.transitionTo()&lt;/code&gt; already throw, straight from the JDK, when a status transition violates the rule the enum itself defines:&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;transitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProductStatus&lt;/span&gt; &lt;span class="n"&gt;nextStatus&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="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;canTransitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nextStatus&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;"Business rule violated: Cannot transition from "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" to "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;nextStatus&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nextStatus&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;An archived product can't become active again, and Chapter 10's test suite already proves &lt;code&gt;changeStatus()&lt;/code&gt; surfaces that as &lt;code&gt;IllegalStateException&lt;/code&gt; rather than silently allowing it. What that test suite couldn't check is what happens to this exception on the far side of the boundary, once it isn't just a Java object being asserted against in a unit test anymore, but something that has to become an HTTP response. The right status for it is 409 Conflict - the client's request is understood perfectly and rejected because of the current state of the resource, which is exactly what 409 exists to describe. It would never succeed no matter how many times it's retried, which makes it a materially different signal from a 500: nothing broke, the rule simply forbids what was asked.&lt;/p&gt;

&lt;p&gt;Worth naming why &lt;code&gt;IllegalStateException&lt;/code&gt; gets a handler at all here rather than a custom &lt;code&gt;InvalidTransitionException&lt;/code&gt; wrapping it: the JDK type already says exactly what happened, and inventing a domain-specific name for it wouldn't add information a caller could use differently. Custom exceptions earn their existence when they carry something a generic type can't - an id, a specific business rule, a distinction the API contract needs to expose. &lt;code&gt;IllegalStateException&lt;/code&gt; here is precise enough on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one exception this codebase doesn't have yet
&lt;/h2&gt;

&lt;p&gt;The third case is different in kind from the first two, because nothing throws it. Not because it's missing by oversight but because the behavior it would protect doesn't exist in the service layer. Creating a &lt;code&gt;Category&lt;/code&gt; named something that already exists should fail with a 409, the same status &lt;code&gt;IllegalStateException&lt;/code&gt; maps to, for the same underlying reason: the request is well-formed, and rejected because of the current state of something the client doesn't fully control - in this case, the uniqueness of a name rather than the legality of a transition. That failure needs a name, &lt;code&gt;ConflictException&lt;/code&gt;, distinct from &lt;code&gt;IllegalStateException&lt;/code&gt; because the two describe different rules even if they land on the same status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConflictException&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&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;ConflictException&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;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;ConflictException&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;message&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="n"&gt;cause&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cause&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;Where it gets thrown from is a decision worth making deliberately rather than by default. The tempting shortcut is a service-level check before &lt;code&gt;dao.create()&lt;/code&gt; - something like &lt;code&gt;assertNoConflict()&lt;/code&gt;, querying for an existing name and throwing if one's found. It reads cleanly, and it mirrors &lt;code&gt;authorize()&lt;/code&gt; closely enough to look like the same pattern. It also introduces a race condition that &lt;code&gt;authorize()&lt;/code&gt; never has to worry about: two requests to create a category with the same name, arriving close enough together, can both pass the existence check before either one has actually inserted a row. Ownership isn't racy the way uniqueness is - nothing else can retroactively make a &lt;code&gt;Product&lt;/code&gt; belong to someone once &lt;code&gt;authorize()&lt;/code&gt; has already confirmed it doesn't. Name uniqueness can be violated by two things happening at once, which a service-level check, no matter how carefully written, cannot fully prevent on its own.&lt;/p&gt;

&lt;p&gt;A unique constraint at the database level closes that race the way application code alone can't, the database itself is the only thing positioned to guarantee it atomically, because it's the only thing both requests are serialized against. The constraint, and the Flyway migration that adds it, belongs to a later chapter, not here. What belongs here is what happens to the violation once it's raised. Spring Data wraps it as &lt;code&gt;DataIntegrityViolationException&lt;/code&gt;, and there are two places to intercept that. The DAO implementation could catch it and rethrow &lt;code&gt;ConflictException&lt;/code&gt; with a message naming the offending field, but doing that reliably means calling &lt;code&gt;saveAndFlush&lt;/code&gt; rather than &lt;code&gt;save&lt;/code&gt;, because &lt;code&gt;save&lt;/code&gt; only registers the entity with the persistence context and the actual INSERT may not happen until the transaction commits, long after the catch block has gone out of scope. Forcing a flush on every create to make a catch block work is a real behavioural change, and a round trip paid on every successful insert to improve the message on the rare failing one.&lt;/p&gt;

&lt;p&gt;The other place is the boundary itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt; &lt;span class="nc"&gt;DataIntegrityViolationException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt; &lt;span class="nf"&gt;handleException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataIntegrityViolationException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Integrity constraint violated: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&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;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFLICT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"The request conflicts with existing data."&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 trade is visible in the message. The DAO knew which object it was creating and could have said "a category named 'Building Sets' already exists", the handler knows only that something violated integrity somewhere, so the response says that and no more. The driver's own message - which names the constraint, and whose format differs between MySQL and H2 goes to the log, for the same reason the catch-all keeps its stack trace there rather than in the response body. At this size that's an acceptable loss, a client that just sent a create request knows what it sent, a 409 tells it the thing already exists, which is the part it can act on. And the DAO-level translation stays available later, for the specific tables where a precise message earns its round trip.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ConflictException&lt;/code&gt; still gets built, but not for this case. Business rules that never touch a constraint will need it: assigning a product to a category that has been archived is a conflict decided entirely in the service layer, with no database involved. That's the next chapter's problem, and the exception will be waiting for it. Notice what didn't happen in either version. &lt;code&gt;AbstractCrudService&lt;/code&gt; gained nothing - no hook, no &lt;code&gt;assertNoConflict()&lt;/code&gt;, not a line. And it couldn't have hosted the catch anyway: &lt;code&gt;DataIntegrityViolationException&lt;/code&gt; is a Spring Data type, and catching it in the service layer would drag persistence knowledge into the business logic module that has stayed free of it since Chapter 7. The same argument that keeps &lt;code&gt;@ResponseStatus&lt;/code&gt; off the domain exceptions keeps this catch out of the service layer, pointing the other direction.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the handler around what's actually there
&lt;/h2&gt;

&lt;p&gt;With three real cases in hand - 403 for a known requester denied by ownership, 409 for a status transition or a name collision the current state forbids, 404 for something that doesn't exist, the handler can be written to match, rather than copied wholesale from somewhere it doesn't quite fit. One decision comes before the first handler method, though: what the error response actually looks like on the wire. The inherited version had its own &lt;code&gt;ResponseDto&lt;/code&gt;, carrying a list of messages and an &lt;code&gt;ErrorCodeDto&lt;/code&gt; enum with constants like &lt;code&gt;NOT_FOUND&lt;/code&gt; and &lt;code&gt;CONFLICT&lt;/code&gt;, resolved from the status code by a switch statement. It worked. It was also a private format that every client would have to learn, and the enum in particular spent its whole existence restating in words what the status code already said in numbers.&lt;/p&gt;

&lt;p&gt;Spring has carried &lt;code&gt;ProblemDetail&lt;/code&gt; since version 6, implementing RFC 9457 - the standard shape for HTTP error responses. Status, title, detail, type, and room for custom fields when a specific error needs them. Choosing it over a bespoke type isn't only about writing less code, though two classes and a factory did disappear along with the decision. It's that a client library, an API gateway, or a generated SDK has a chance of recognizing a standard format, where &lt;code&gt;ResponseDto&lt;/code&gt; would always have needed documentation and hand-written parsing. The series keeps reaching for the same rule: don't build your own until the standard one is demonstrably not enough.&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;@RestControllerAdvice&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;GeneralExceptionHandler&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntityExceptionHandler&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Logger&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoggerFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLogger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GeneralExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;HEADER_REQUESTER_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"X-Requester-Id"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt; &lt;span class="nc"&gt;AuthorizationException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt; &lt;span class="nf"&gt;handleException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuthorizationException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization failed: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&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;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FORBIDDEN&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt; &lt;span class="nc"&gt;NotFoundException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt; &lt;span class="nf"&gt;handleException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotFoundException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Entity not found: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&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;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt; &lt;span class="nc"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt; &lt;span class="nf"&gt;handleException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IllegalStateException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Invalid state transition: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&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;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFLICT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt; &lt;span class="nc"&gt;ConflictException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt; &lt;span class="nf"&gt;handleException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ConflictException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Conflict: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&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;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFLICT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt; &lt;span class="nf"&gt;handleException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unhandled exception"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INTERNAL_SERVER_ERROR&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"An unexpected error occurred."&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;Five specific handlers, one catch-all. Nothing here maps a status this codebase doesn't have a reason to produce, and nothing here is missing that the current business logic actually needs. &lt;code&gt;ForbiddenException&lt;/code&gt;, &lt;code&gt;VerificationException&lt;/code&gt;, &lt;code&gt;InvalidSessionException&lt;/code&gt; aren't in this file because nothing throws them yet - not because they're wrong ideas, but because adding them now would mean guessing at a shape for a feature (session and device tracking, arriving properly in a later chapter on securing the boundary) before that feature exists to define what the exception should actually carry. An exception hierarchy built ahead of the behavior it's meant to describe tends to guess wrong about the details that matter, and then either gets reshaped later or, more often, just sits there unused while the real need gets solved some other way anyway.&lt;/p&gt;

&lt;p&gt;Notice also that the log level varies with the case. A &lt;code&gt;warn&lt;/code&gt; for the four named exceptions, because a client asking for something they don't own or a transition the rules forbid is the system working correctly, not breaking - noisy at &lt;code&gt;error&lt;/code&gt; level, and quickly trained out of anyone's attention. The catch-all logs at &lt;code&gt;error&lt;/code&gt;, because something reaching it means nobody anticipated this at all. That distinction is worth the two extra seconds it takes to make, and it's the kind of thing a shared &lt;code&gt;ResponseFactory&lt;/code&gt; flattens by accident: one code path for every case means one log level for every case.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two failures that never reach the business logic
&lt;/h2&gt;

&lt;p&gt;Everything above assumes the request made it far enough to become a method call with real arguments. Two failures happen before that, and both belong here rather than anywhere deeper, because at the point they occur there is no domain object yet to be wrong about.&lt;/p&gt;

&lt;p&gt;The first is a request body that can't be parsed. Malformed JSON, a truncated payload, a content type that doesn't match what arrived - Jackson fails, and Spring raises &lt;code&gt;HttpMessageNotReadableException&lt;/code&gt; before any controller method is invoked. &lt;code&gt;ResponseEntityExceptionHandler&lt;/code&gt;, the base class this handler extends, already has a hook for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Override&lt;/span&gt;
&lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleHttpMessageNotReadable&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;HttpMessageNotReadableException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;HttpHeaders&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;HttpStatusCode&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;WebRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;logger&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;"Unreadable request body: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Request body could not be read."&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;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;problem&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 response says the body couldn't be read and stops there. Jackson's own message is more specific - it will name the line, the column, the token it choked on, sometimes the target class it was trying to build - and all of that goes to the log instead. A client that sent malformed JSON knows what it sent; it doesn't need the parser's internal account of where the parse gave up, and that account happens to reveal the shape of the type on the other side.&lt;/p&gt;

&lt;p&gt;The second is an argument that arrives but can't be converted. A path variable declared as &lt;code&gt;UUID&lt;/code&gt; receiving &lt;code&gt;not-a-uuid&lt;/code&gt;, for instance. Spring raises &lt;code&gt;MethodArgumentTypeMismatchException&lt;/code&gt;, and the obvious mapping is 400 - the client sent something the endpoint can't use. There's one exception to that, and it's the reason this handler has a branch in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt; &lt;span class="nc"&gt;MethodArgumentTypeMismatchException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt; &lt;span class="nf"&gt;handleException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MethodArgumentTypeMismatchException&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;HEADER_REQUESTER_ID&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;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&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;"Request arrived without a usable requester id"&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;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Client is not authenticated."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Malformed argument '{}': {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&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;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Malformed value for '"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&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;Every service method in this codebase takes a &lt;code&gt;requesterId&lt;/code&gt;, and it arrives as a header the gateway is expected to set. If it's absent or unparseable, the honest reading isn't "you sent a malformed argument" - it's that the caller never identified itself at all, which is the textbook 401. The same mechanical failure means two different things depending on which argument failed, and the only way to tell them apart is to name the one that matters. That name is a single constant on the handler for now. It's really part of the API contract rather than the error-handling layer, and it will likely move once the API module exists - but a constant referenced from two places beats the same string literal typed independently in the handler and the tests, where a later rename would silently break the branch without the compiler noticing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Proving the boundary holds
&lt;/h2&gt;

&lt;p&gt;The mocked-DAO suite from the last two chapters proves &lt;code&gt;AbstractCrudService.update()&lt;/code&gt; throws &lt;code&gt;AuthorizationException&lt;/code&gt; when a non-owner tries to modify something. It says nothing about what a client actually receives over HTTP when that happens, because &lt;code&gt;GeneralExceptionHandler&lt;/code&gt; sits entirely outside that suite's reach - a different class, a different module, a translation step the service-layer tests were never positioned to exercise. Trusting that the mapping is correct just because the exception fires correctly one layer down is exactly the gap this chapter opened by walking into. Closing it needs a test that goes through Spring's MVC dispatch machinery far enough to reach the &lt;code&gt;@ExceptionHandler&lt;/code&gt; method, not just the service method that threw. There are no controllers in this codebase yet, which rules out &lt;code&gt;@WebMvcTest&lt;/code&gt; - but a standalone &lt;code&gt;MockMvc&lt;/code&gt; setup needs no application context at all. It takes a controller instance, an advice instance, and wires the dispatch between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GeneralExceptionHandlerTestCase&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;MockMvc&lt;/span&gt; &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@BeforeEach&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;setUpMockMvc&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;mockMvc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MockMvcBuilders&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;standaloneSetup&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;ThrowingController&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setControllerAdvice&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;GeneralExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;whenAuthorizationExceptionIsThrown_ShouldReturn403WithProblemDetail&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;perform&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/test/authorization"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isForbidden&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonPath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$.status"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonPath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$.detail"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Client is not authorized for this operation."&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;whenRequesterIdIsMalformed_ShouldReturn401&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;perform&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/test/requester"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GeneralExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HEADER_REQUESTER_ID&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"not-a-uuid"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isUnauthorized&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonPath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$.status"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonPath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$.detail"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Client is not authenticated."&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;whenAnotherArgumentIsMalformed_ShouldReturn400&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;perform&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/test/product/{id}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"not-a-uuid"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isBadRequest&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonPath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$.status"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonPath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$.detail"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Malformed value for 'id'."&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@RestController&lt;/span&gt;
    &lt;span class="nd"&gt;@RequestMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/test"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ThrowingController&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/authorization"&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;authorization&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;AuthorizationException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Client is not authorized for this operation."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/requester"&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;requester&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GeneralExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HEADER_REQUESTER_ID&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Never reached - the conversion fails first&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/product/{id}"&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;product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Never reached - the conversion fails first&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 controller is a nested static class that exists for one purpose: to throw. It has no service dependency, no business logic, no place in the production source tree. When real controllers arrive, these tests can move onto &lt;code&gt;@WebMvcTest&lt;/code&gt; against them - but waiting for that would mean the handler shipping untested through at least one chapter, and the two argument tests above are the pair that actually earns its keep, because they are the only thing distinguishing the two branches of a method where the difference is a 401 versus a 400.&lt;/p&gt;

&lt;p&gt;The full suite has one test per mapping, plus one that does something slightly different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;whenUnexpectedExceptionIsThrown_ShouldNotLeakTheOriginalMessage&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;perform&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/test/unexpected"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andReturn&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getResponse&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContentAsString&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// The raw message of an unanticipated failure must never reach the client -&lt;/span&gt;
    &lt;span class="c1"&gt;// it may carry internal details never written with an external reader in mind.&lt;/span&gt;
    &lt;span class="n"&gt;assertFalse&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="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Connection refused to internal-db-host:3306"&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;Every other test asserts that something is present. This one asserts that something is absent, and it's the only kind that can catch the failure it's aimed at. A regression that swapped the fixed message back for &lt;code&gt;e.getMessage()&lt;/code&gt; would leave the status at 500 and every other assertion green; the only visible difference would be a hostname and a port in the response body that nobody was looking for. The controller it hits throws a &lt;code&gt;RuntimeException&lt;/code&gt; carrying exactly the sort of message a real infrastructure failure produces, and the test's whole job is to confirm the client never sees it.&lt;/p&gt;

&lt;p&gt;This is a genuinely different assertion from anything in the mocked-DAO suite, and it's worth being precise about why. The service-layer tests prove &lt;em&gt;what exception fires and under what condition&lt;/em&gt; - a business-logic question, answered once and inherited by everything extending &lt;code&gt;AbstractCrudService&lt;/code&gt;. These prove &lt;em&gt;what a client sees once that exception crosses the boundary&lt;/em&gt; - a question about the handler, answerable only by exercising the handler, and one a 403-versus-401 mistake would sail straight through undetected, because nothing at the service layer would ever have been wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the catch-all gives away
&lt;/h2&gt;

&lt;p&gt;The catch-all is the last line of defense for anything genuinely unexpected, and what it does with that failure matters more, not less. Two lines of it carry the whole weight:&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;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt; &lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt; &lt;span class="nf"&gt;handleException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unhandled exception"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ProblemDetail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forStatusAndDetail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INTERNAL_SERVER_ERROR&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"An unexpected error occurred."&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;Passing the throwable as SLF4J's second argument rather than pre-extracting &lt;code&gt;e.getMessage()&lt;/code&gt; is what preserves the stack trace. The single-argument overload logs the message text and discards everything else - which in the handler for unanticipated failures means throwing away the one piece of information that says where in the code the failure originated. It's an easy line to write the wrong way, and the wrong way looks identical in a code review until someone needs the trace and finds it missing. The response goes the other direction, for the four named exceptions, &lt;code&gt;e.getMessage()&lt;/code&gt; is something this codebase wrote deliberately, meant to be read by whoever's on the other end. For anything reaching the catch-all, the message could be whatever an unanticipated &lt;code&gt;NullPointerException&lt;/code&gt; or a database driver happens to produce - never vetted for what it's safe to expose, and going out over the wire regardless. A fixed string instead costs a client with a genuine 500 nothing they could have acted on anyway, the fix isn't theirs to make, and the detail they'd need is in the log where it belongs.&lt;/p&gt;

&lt;p&gt;Every change in this chapter lives entirely in the exception handler - not one line of &lt;code&gt;AbstractCrudService&lt;/code&gt;, &lt;code&gt;Product&lt;/code&gt;, or &lt;code&gt;Category&lt;/code&gt; needed to move. That's not incidental. A layered architecture is only worth the discipline it costs if the translation boundary can be built, corrected, and grown on its own terms, without disturbing the business logic it protects. The inherited handler this chapter started by setting aside wasn't wrong for the project it came from. It was wrong for this one, in the specific sense that matters here: it answered questions this codebase hasn't asked yet. &lt;code&gt;ForbiddenException&lt;/code&gt; for a distinction between kinds of denial that don't yet exist. &lt;code&gt;VerificationException&lt;/code&gt; for a verification flow that hasn't been designed. &lt;code&gt;InvalidSessionException&lt;/code&gt; for a session model that belongs to a chapter still several weeks out. Copying it in would have meant this boundary carrying more shape than the system behind it does - the same failure mode, in spirit, as forcing &lt;code&gt;changeStatus()&lt;/code&gt; into a shared base class before its two implementations had any reason to diverge, just moved one layer further out, from the business logic to the code that translates its failures.&lt;/p&gt;

&lt;p&gt;What got built instead covers exactly what &lt;code&gt;ProductsServiceImpl&lt;/code&gt; and &lt;code&gt;CategoriesServiceImpl&lt;/code&gt; throw today, plus the two failures that happen before they're ever called, nothing more, and nothing pretending to be more finished than it is. When session tracking arrives, &lt;code&gt;InvalidSessionException&lt;/code&gt; will get built the same way everything here did: starting from a real thrown exception, not from a name that sounded like it might eventually be useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;Every domain object this series has built so far fits the same operations: created, read, updated, deleted, owned by whoever made it. &lt;code&gt;AbstractCrudService&lt;/code&gt; exists because &lt;code&gt;Product&lt;/code&gt; and &lt;code&gt;Category&lt;/code&gt; genuinely share that shape, and a third or fourth entity of the same kind would inherit all of it for free.&lt;/p&gt;

&lt;p&gt;The next chapter introduces one that doesn't fit at all. A &lt;code&gt;CategoryAssignment&lt;/code&gt; records that a product belongs to a category, who put it there, and when and that's the whole of it. There is no update operation, because an assignment is a fact rather than a thing with a lifecycle. You either made it or you didn't, and changing it means removing one and creating another. Forcing it into &lt;code&gt;AbstractCrudService&lt;/code&gt; would mean implementing an update path that has no meaning, purely to satisfy a contract it was never shaped for. It also gets interesting for a second reason. Assigning one product to twenty categories, or twenty products to one, is the normal case rather than the exception, which makes the bulk operation the primary API rather than a convenience wrapper around a single-item one. That raises a question CRUD never had to answer: what happens when half the batch succeeds. Some categories are archived and can't accept new products, some ids don't resolve to anything, some assignments already exist. Chapter 12 builds that path end to end - domain, DAO, persistence tests, service, including where the transaction boundary belongs when partial failure is the expected outcome rather than a bug.&lt;/p&gt;




&lt;h3&gt;
  
  
  Codebase &amp;amp; Architecture Blueprint
&lt;/h3&gt;

&lt;p&gt;The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 11, use the following link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Repository (Tag: &lt;code&gt;chapter-11-exception-handling&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/KamenIvanov/advanced-spring-multimodule/tree/chapter-11-exception-handling" rel="noopener noreferrer"&gt;advanced-spring-multimodule&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: All core modules are configured with strict compilation-level boundaries. Compile and run &lt;code&gt;mvn clean install&lt;/code&gt; to see the structure in action. Maven version 3.9.* and Java 25 are required.&lt;/p&gt;

&lt;p&gt;◀️ &lt;a href="https://dev.to/kamenivanov/testing-the-service-layer-part-2-where-the-shared-ancestor-ends-chapter-10-1m17"&gt;Testing the Service Layer - Part 2: Where the Shared Ancestor Ends (Chapter 10)&lt;/a&gt;&lt;br&gt;
▶️ &lt;a href="https://dev.to/kamenivanov/the-domain-type-that-doesnt-fit-many-to-many-links-and-bulk-operations-chapter-12-id"&gt;Read Chapter 12 - The Domain Type That Doesn't Fit&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Join the Masterclass Journey
&lt;/h3&gt;

&lt;p&gt;This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, &lt;strong&gt;&lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;subscribe to my Substack Newsletter here&lt;/a&gt;&lt;/strong&gt;!&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;Disclosure: I earn a commission if you purchase through the links below, at no additional cost to you.&lt;/p&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code&lt;/em&gt; &lt;strong&gt;&lt;em&gt;friends20&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;for an exclusive&lt;/em&gt; &lt;strong&gt;&lt;em&gt;20% discount&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Management rejected three tech debt proposals. The fourth one passed because I spoke their language.</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:11:24 +0000</pubDate>
      <link>https://dev.to/kamenivanov/management-rejected-three-tech-debt-proposals-the-fourth-one-passed-because-i-spoke-their-language-97c</link>
      <guid>https://dev.to/kamenivanov/management-rejected-three-tech-debt-proposals-the-fourth-one-passed-because-i-spoke-their-language-97c</guid>
      <description>&lt;p&gt;The first proposal was two paragraphs about code quality, mentioning a module that had become hard to change safely and a request for two weeks to refactor it. It got a polite no, the kind that comes with "let's revisit next quarter" attached. The second was longer, with more technical detail about coupling and test coverage, and got the same answer. The third had a diagram. Still no.&lt;/p&gt;

&lt;p&gt;Each version had been written the way engineers write for other engineers, describing the problem in terms of the code itself, not in terms of anything the room deciding on it actually had to weigh against. Management wasn't rejecting the need, they were rejecting proposals that gave them nothing to compare against the other things competing for the same two weeks of engineering time. "The code is hard to maintain" isn't a data point next to a feature request with a customer's name attached to it, it's a feeling, and feelings lose to deadlines every time in that comparison.&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%2Fpkjt7o9wugbuivbcvzko.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%2Fpkjt7o9wugbuivbcvzko.png" alt="Tech depth Proposals" width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fourth version dropped the word "refactor" entirely from the opening. It opened with a number: the module in question had been responsible for three production incidents in a single quarter, each with measurable time-to-resolution, and every new feature touching that module had been taking roughly 40% longer to ship than comparable work elsewhere, based on ticket history anyone could pull. The ask was reframed from "let us improve code quality" to "here's what continuing to build on this module is currently costing in incident time and delivery time, and here's the two-week investment that stops the bleeding." Same underlying work but completely different proposal.&lt;/p&gt;

&lt;p&gt;It passed in the same meeting it was presented in, which was less about the argument being cleverer and more about it finally being denominated in the currency the room was actually pricing decisions in. Nobody in that meeting disagreed that clean code was good. They disagreed, reasonably, on whether "good code" as a goal on its own was worth displacing a committed roadmap item, and no version of the pitch that stayed in that register was ever going to win that comparison.&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%2Fpqn4xvie2w0wpg2zhf3v.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%2Fpqn4xvie2w0wpg2zhf3v.png" alt="Incident Frequency" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If a tech debt proposal keeps getting deferred, the proposal is usually not wrong about the problem, it's answering a question nobody in that meeting is asking. The question being asked is almost always some version of what does this cost us if we don't, and what does it cost us if we do, both in numbers a non-engineer can hold in their head. Translate the argument into that shape before assuming the answer is politics.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More like this?&lt;/strong&gt;&lt;br&gt;
I write short production war stories like this one, plus a deep-dive architecture series. &lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;Subscribe here&lt;/a&gt; if you want both in your inbox.&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code&lt;/em&gt; &lt;strong&gt;&lt;em&gt;friends20&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;for an exclusive&lt;/em&gt; &lt;strong&gt;&lt;em&gt;20% discount&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>management</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>An Engineer Left ddl-auto=update in Staging. Production Ended Up With Two Columns Instead of One.</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Wed, 23 Sep 2026 11:55:39 +0000</pubDate>
      <link>https://dev.to/kamenivanov/an-engineer-left-ddl-autoupdate-in-staging-production-ended-up-with-two-columns-instead-of-one-3gdd</link>
      <guid>https://dev.to/kamenivanov/an-engineer-left-ddl-autoupdate-in-staging-production-ended-up-with-two-columns-instead-of-one-3gdd</guid>
      <description>&lt;p&gt;The deployment window was Sunday night specifically because it was supposed to be quiet, the lowest-traffic slot in the week, the safest possible time to ship a schema change nobody expected to notice. What went out that night was a rename - one column, &lt;code&gt;legacy_flag&lt;/code&gt; becoming &lt;code&gt;status_code&lt;/code&gt;, part of a larger cleanup that had been sitting in the backlog for months. The migration script was correct. The problem was that it never ran.&lt;/p&gt;

&lt;p&gt;The properties file behind it was meant for staging. It had been copied into the production configuration months earlier during an environment setup nobody fully audited afterward, and it carried two settings that should never have left staging: &lt;code&gt;spring.flyway.enabled=false&lt;/code&gt; and &lt;code&gt;spring.jpa.hibernate.ddl-auto=update&lt;/code&gt;. The first meant the migration was never executed. The second meant Hibernate filled the gap. With &lt;code&gt;update&lt;/code&gt; active, Hibernate inspects the mapped domain objects at startup and alters the schema to match what the code expects. The application redeployed, Hibernate saw an entity mapped to &lt;code&gt;status_code&lt;/code&gt;, found no such column, and quietly added it, empty. &lt;code&gt;legacy_flag&lt;/code&gt; stayed exactly where it was with its data intact, but nothing read from it or wrote to it anymore. No migration ran and no rename happened. The table now had two columns: one holding all the history, and one the application actually used.&lt;/p&gt;

&lt;p&gt;Nobody noticed for the first eleven minutes, because nothing threw an exception. &lt;code&gt;legacy_flag&lt;/code&gt; was nullable, so inserts that no longer populated it went through without complaint. Reads against &lt;code&gt;status_code&lt;/code&gt; returned null, which is a valid value, not an error. Reports depending on that column started showing empty fields instead of failing loudly, and it took a downstream team asking why customer statuses looked wrong before anyone connected it to the deployment. The data wasn't lost, it was stranded. Every write since the deploy had gone to a column the old rows didn't have, while &lt;code&gt;legacy_flag&lt;/code&gt; held values that were now stale. Copying one column into the other was a single statement. Deciding which value to trust for every row touched in between took far longer than the fix itself.&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%2Fdb5krfug77rr0y1hujln.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%2Fdb5krfug77rr0y1hujln.png" alt="DDL Autoupdate decision path" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What made &lt;code&gt;ddl-auto=update&lt;/code&gt; dangerous here wasn't that it did something wrong. It did exactly what it's designed to do: keep the schema in sync with the domain objects, with zero migration tooling in the loop. That's a reasonable convenience for a local environment where the database resets constantly and nobody cares about history. In any environment holding real data, the same behavior means the schema is one refactor away from being rewritten by inference instead of by an explicit, reviewed script. Renaming a field in code isn't a rename to Hibernate. It's a new, empty column appearing next to an old one that is silently orphaned, and Hibernate does that at startup, without asking.&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%2Fc0xueojx91h8tz68xllf.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%2Fc0xueojx91h8tz68xllf.png" alt="Recovery Timeline Column Drop" width="800" height="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix wasn't just re-enabling Flyway and flipping the setting to &lt;code&gt;validate&lt;/code&gt;, though both happened within the hour. With &lt;code&gt;validate&lt;/code&gt;, the same deploy would have refused to start on a missing column instead of quietly creating one. The real fix was auditing every environment's configuration against a single source of truth, instead of trusting that a staging properties file, once copied, stays where it was copied to. If your production configuration was ever bootstrapped from a staging template, it's worth checking whether that inheritance is still intentional or just inherited.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More like this?&lt;/strong&gt;&lt;br&gt;
I write short production war stories like this one, plus a deep-dive architecture series. &lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;Subscribe here&lt;/a&gt; if you want both in your inbox.&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code&lt;/em&gt; &lt;strong&gt;&lt;em&gt;friends20&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;for an exclusive&lt;/em&gt; &lt;strong&gt;&lt;em&gt;20% discount&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>devops</category>
      <category>java</category>
    </item>
    <item>
      <title>Testing the Service Layer - Part 2: Where the Shared Ancestor Ends (Chapter 10)</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:48:39 +0000</pubDate>
      <link>https://dev.to/kamenivanov/testing-the-service-layer-part-2-where-the-shared-ancestor-ends-chapter-10-1m17</link>
      <guid>https://dev.to/kamenivanov/testing-the-service-layer-part-2-where-the-shared-ancestor-ends-chapter-10-1m17</guid>
      <description>&lt;p&gt;Part 1 of this chapter was about everything &lt;code&gt;AbstractCrudServiceTestCase&lt;/code&gt; gets to say once, for every CRUD service that will ever extend it - ownership stamping, idempotent delete, the authorization path shared by &lt;code&gt;update()&lt;/code&gt;, &lt;code&gt;loadById()&lt;/code&gt;, and &lt;code&gt;delete()&lt;/code&gt; alike. That suite works precisely because the behavior it covers really is shared: the same rule, expressed once, correctly inherited by anything that extends the base class. But not everything on &lt;code&gt;ProductsServiceImpl&lt;/code&gt; and &lt;code&gt;CategoriesServiceImpl&lt;/code&gt; fits that description, and the clearest example is a method the abstract suite never touches at all.&lt;/p&gt;




&lt;h3&gt;
  
  
  Twins that aren't ancestors
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;changeStatus()&lt;/code&gt; exists independently on both concrete services, and today, the two implementations are nearly identical:&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;@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;changeStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProductStatus&lt;/span&gt; &lt;span class="n"&gt;newStatus&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;requesterId&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;requesterId&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AuthorizationException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loadOrThrowNotFound&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;getDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;loadById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;authorize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requesterId&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;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;newStatus&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newStatus&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setUpdatedById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;getDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A same-status request deserved its own explicit decision rather than being left to chance. &lt;code&gt;ProductStatus.ACTIVE.canTransitionTo(ACTIVE)&lt;/code&gt; returns false, so without the guard above, requesting a transition to the status a product already holds would throw &lt;code&gt;IllegalStateException&lt;/code&gt;. A client retrying a timed-out request would see a failure for a change that had already succeeded. The check mirrors &lt;code&gt;delete()&lt;/code&gt;'s idempotency rather than inventing a new definition of it. If nothing would actually change, nothing gets written, not even the audit fields. A retry that touched &lt;code&gt;updatedById&lt;/code&gt; on a no-op would leave the audit trail implying movement that never happened. The authorization check still runs before the status comparison. A non-owner requesting the current status doesn't get a silent pass just because nothing would change. &lt;/p&gt;

&lt;p&gt;It would be tempting to read that duplication as an oversight. Surely this belongs on &lt;code&gt;AbstractCrudService&lt;/code&gt;, right next to &lt;code&gt;create()&lt;/code&gt; and &lt;code&gt;update()&lt;/code&gt;. It doesn't, for two separate reasons, and both of them are more instructive than the duplication itself.&lt;/p&gt;

&lt;p&gt;The first is structural: not every domain in this system has a status at all. &lt;code&gt;AbstractCrudService&lt;/code&gt; is generic over any &lt;code&gt;Domain extends AbstractAuditable&amp;lt;UUID&amp;gt;&lt;/code&gt;, and forcing a status concept onto that hierarchy would mean either a generic hook that adds ceremony to every future service that never needs status, for example an &lt;code&gt;Order&lt;/code&gt; that has no status field to speak of, or a leaky assumption baked into a base class that should know nothing about what any particular domain represents. &lt;code&gt;AbstractAuditable&lt;/code&gt; knows about identity and ownership, because every domain object in this system has those. It doesn't know about status, because most won't.&lt;/p&gt;

&lt;p&gt;The second reason is the one that actually matters going forward, and it's about where these two methods are headed, not where they are today. &lt;code&gt;Product&lt;/code&gt; and &lt;code&gt;Category&lt;/code&gt; don't share transition rules: &lt;code&gt;ProductStatus&lt;/code&gt; allows a product to move from &lt;code&gt;ACTIVE&lt;/code&gt; to &lt;code&gt;OUT_OF_STOCK&lt;/code&gt; and back, but &lt;code&gt;CategoryStatus&lt;/code&gt; has no equivalent at all. And the side effects each &lt;code&gt;changeStatus()&lt;/code&gt; will eventually need to trigger are going to diverge in a way that has nothing to do with today's identical-looking code. When this system integrates with Kafka for event broadcasting, publishing a product to &lt;code&gt;ACTIVE&lt;/code&gt; will very likely need to fire an event - a storefront needs to know a product became purchasable. Archiving a category may need to fire an event too, but only on that specific transition, not on every status change a category goes through. Those are two different rules about two different triggers, and they belong on two different methods, sitting inside two different classes, exactly where they already are.&lt;/p&gt;

&lt;p&gt;If that behavior had been forced into a shared base method ahead of time - a generic &lt;code&gt;onStatusChanged()&lt;/code&gt; hook, parameterized somehow to cover both cases, the divergence would have had nowhere clean to go. You'd end up either parameterizing the hook into meaninglessness, threading flags and conditionals through a method that was supposed to be simple, or growing base-class conditionals that only apply to some subclasses, which is the exact kind of rot this whole series argues against. Two classes having identical code today is evidence of nothing on its own. The real question is whether they're identical because they're expressing the same concept, or identical because they haven't yet diverged. &lt;code&gt;authorize()&lt;/code&gt; is the same rule in both classes and will very likely stay the same rule, because ownership-based authorization isn't domain-specific - it's genuinely shared ancestry. &lt;code&gt;changeStatus()&lt;/code&gt; is the same code in both classes today for the far less interesting reason that neither one has grown its side effects yet. Promoting the first into the base class was correct. Promoting the second would have been premature abstraction wearing the same clothes as legitimate reuse and premature abstraction is arguably the worse failure of the two, because duplication is at least honest about not knowing yet, while a wrong abstraction actively resists the divergence when it finally shows up.&lt;/p&gt;

&lt;p&gt;The test suite reflects that boundary. Each concrete class carries its own full set of &lt;code&gt;changeStatus()&lt;/code&gt; tests - null requester, missing entity, wrong owner, valid transition, illegal transition and none of that coverage comes from the abstract suite, because none of it can:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;changeStatus_WhenTransitionIsIllegal_ShouldThrowAndNeverPersist&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;persistedProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProductStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ARCHIVED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productDao&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loadById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())).&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;assertThrows&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;productsService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;changeStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;ProductStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACTIVE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productDao&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;never&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not a gap in the abstraction. It's the cost of the abstraction being drawn in the right place. "Extend, don't rewrite" only works as a principle if you're also willing to say, clearly, which things were never meant to be extended from a shared ancestor in the first place. Worth naming directly, while we're on &lt;code&gt;changeStatus()&lt;/code&gt;: nothing in this suite would have caught it missing &lt;code&gt;@Transactional&lt;/code&gt;. Both implementations follow the same read-modify-write shape as &lt;code&gt;update()&lt;/code&gt; - load, mutate, persist and &lt;code&gt;update()&lt;/code&gt; carries that annotation for exactly the reason discussed in Chapter 9: it's declarative metadata, not a framework leak, and it matters more once a side effect like Kafka event publishing eventually sits inside the same method. A mocked-DAO test verifies what a method does which calls happen, in what order, under what conditions, but the transactional boundary around those calls is applied by a Spring AOP proxy that never exists in this test setup at all. Catching a missing or misplaced &lt;code&gt;@Transactional&lt;/code&gt; requires an integration test with a real Spring context, which is precisely the layer this chapter chose not to test. That's not a flaw in the mocked-DAO approach - it's a reminder that "100% service-layer coverage" from unit tests alone was never actually 100% of what could go wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  A specification hiding in a branch
&lt;/h2&gt;

&lt;p&gt;Coverage tooling can tell you that a line or branch executed. It cannot tell you whether the test data and assertions proved the behavior that branch exists to protect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSpecification&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;ProductSpecification&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDimensions&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getWeight&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setDimensions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDimensions&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getWeight&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;Before this test was added, every update test built an existing product without a specification. The update path therefore always took the &lt;code&gt;if&lt;/code&gt; branch and created a new &lt;code&gt;ProductSpecification&lt;/code&gt;. The &lt;code&gt;else&lt;/code&gt; branch - the normal path for a product that already has a specification was never exercised with a fixture that could prove its intended behavior: mutating the existing object in place rather than replacing it with an equal-looking new one.&lt;/p&gt;

&lt;p&gt;The gap was easy to miss because the suite still had broad coverage around updates, and the transformer appeared to work for the creation case. But a flipped condition, a replacement where mutation was intended, or a broken in-place update could all survive until production. The missing test was not merely about increasing a coverage percentage, It had to make the identity of the existing &lt;code&gt;ProductSpecification&lt;/code&gt; part of the contract. The fix needed the domain to carry meaningful dimensions and weight, plus a test that could distinguish “the transformer mutated the existing object” from “the transformer built an equal-looking new one.” &lt;code&gt;assertEquals&lt;/code&gt; cannot make that distinction on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;update_WhenSpecificationAlreadyExists_ShouldMutateInPlaceRatherThanReplace&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;persistedProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProductStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DRAFT&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSpecification&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;ProductSpecification&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"10x10x10cm"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;450&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProductSpecification&lt;/span&gt; &lt;span class="n"&gt;originalSpecification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;UpdateProduct&lt;/span&gt; &lt;span class="n"&gt;updateDto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createUpdateDomain&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productDao&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loadById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())).&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productDao&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;productsService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;updateDto&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// The transformer's else-branch mutates the existing specification rather than&lt;/span&gt;
    &lt;span class="c1"&gt;// allocating a new one - this asserts that behavior directly, not just its effect.&lt;/span&gt;
    &lt;span class="n"&gt;assertSame&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;originalSpecification&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updateDto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDimensions&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getDimensions&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updateDto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getWeight&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getWeight&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;assertSame&lt;/code&gt; is doing real work in that test, not just being pedantic. It's the only way to actually prove the &lt;code&gt;else&lt;/code&gt; branch ran and mutated the existing object, rather than the &lt;code&gt;if&lt;/code&gt; branch running and happening to produce a new object that merely looks equal. Those are two different code paths, guarding against two different bugs, and a weaker assertion would have let either one hide behind the other.&lt;/p&gt;




&lt;h2&gt;
  
  
  When a fixture stops earning its keep
&lt;/h2&gt;

&lt;p&gt;The suite went through one more revision worth describing, not because the bug it fixed was dramatic, but because it happened inside the test code itself, and the same discipline that applies to production code turned out to apply there too. The original fixture for building an already-persisted entity - needed by every update, delete, and loadById test that requires an existing owner routed through the service's own &lt;code&gt;create()&lt;/code&gt; method:&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="nc"&gt;Domain&lt;/span&gt; &lt;span class="nf"&gt;createPersistedEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;entityId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="o"&gt;())).&lt;/span&gt;&lt;span class="na"&gt;thenAnswer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;withId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getArgument&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;entityId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt; &lt;span class="n"&gt;persisted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getService&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;createValidNewDomain&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;assertNotNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;persisted&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entityId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;persisted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;persisted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedById&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="n"&gt;clearInvocations&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&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;persisted&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 worked, but it created a dependency that had no business existing: every update, delete, and loadById test in the suite now implicitly depended on &lt;code&gt;create()&lt;/code&gt; behaving correctly, purely to get a fixture into existence. If &lt;code&gt;create()&lt;/code&gt; ever broke - a transformer throwing, an authorization check misfiring, every ownership test across every concrete service would fail alongside it, for a reason that had nothing to do with what those tests were actually named after. That's a diagnostic cost paid every time someone opens a build with ten failing tests and has to work backward to discover that only one of them represents a real bug. Worse, it needed &lt;code&gt;clearInvocations()&lt;/code&gt; afterward, purely to erase the trace that fixture setup had left on the mock, a workaround whose only purpose was to hide the coupling it had just introduced. The replacement drops the indirection entirely, each concrete class already had, separately, a direct helper that builds a persisted entity through its own constructor - no mock, no service call, no cleanup required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt; &lt;span class="nf"&gt;buildPersistedEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;ownerId&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;Domain&lt;/span&gt; &lt;span class="nf"&gt;persistedEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;buildPersistedEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Override&lt;/span&gt;
&lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="nf"&gt;buildPersistedEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;persistedProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProductStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DRAFT&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="nf"&gt;persistedProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProductStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;product&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;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Existing Product"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSku&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"EXISTING-SKU"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setPrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEN&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCreatedById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ownerId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setUpdatedById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ownerId&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;product&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 &lt;code&gt;withId()&lt;/code&gt; has exactly one caller left - the create-ownership test, the one place that genuinely needs to simulate what a real DAO does on persistence and &lt;code&gt;buildPersistedEntity()&lt;/code&gt; has exactly one job, building a fixture, with zero coupling to whether &lt;code&gt;create()&lt;/code&gt; happens to work on a given day. Two mechanisms had grown up to solve what was really one problem, and the fix wasn't clever, just a refusal to let that stand. It's worth naming directly: the same principle this whole chapter argues for in production code - extend or override, don't quietly duplicate a slightly different version of the same mechanism applies just as much to the tests themselves. A test suite that hasn't had this kind of pass run over it tends to accumulate exactly this shape of redundancy, because nobody sets out to write two fixture mechanisms, they arrive one at a time, each locally reasonable, and only look wrong once they're standing next to each other.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this buys, concretely
&lt;/h2&gt;

&lt;p&gt;Put the whole suite next to the two concrete classes and the claim this chapter opened with is checkable rather than rhetorical. &lt;code&gt;AbstractCrudServiceTestCase&lt;/code&gt; carries every test for &lt;code&gt;create()&lt;/code&gt;, &lt;code&gt;update()&lt;/code&gt;, &lt;code&gt;loadById()&lt;/code&gt;, and &lt;code&gt;delete()&lt;/code&gt; - authorization guards, not-found guards, ownership stamping, idempotent delete - written once, against the shape every future CRUD service will share. &lt;code&gt;ProductsServiceTestCase&lt;/code&gt; and &lt;code&gt;CategoriesServiceTestCase&lt;/code&gt; add only what's actually theirs: field mapping through their own transformer, the specification branch in &lt;code&gt;Product&lt;/code&gt;'s case, and a full &lt;code&gt;changeStatus()&lt;/code&gt; suite each, because that behavior was never ancestral to begin with. A future &lt;code&gt;OrdersServiceImpl&lt;/code&gt; extending &lt;code&gt;AbstractCrudService&lt;/code&gt; inherits the first category for free, the same way &lt;code&gt;ProductsServiceImpl&lt;/code&gt; and &lt;code&gt;CategoriesServiceImpl&lt;/code&gt; did, and only has to write tests for whatever is genuinely its own.&lt;/p&gt;

&lt;p&gt;That's the return on the investment this chapter spent both of its halves on: not a percentage on a coverage report, but a shrinking marginal cost for every service this codebase adds from here forward, a boundary drawn deliberately between what gets inherited and what gets grown independently, and a test suite that fails for the reason its name says it will fail, and nothing else.&lt;/p&gt;




&lt;h3&gt;
  
  
  What's Next?
&lt;/h3&gt;

&lt;p&gt;The service layer this chapter tested stays deliberately ignorant of anything outside it - no Spring context, no HTTP, no idea that a REST controller exists. That ignorance has a cost that hasn't been paid yet: when &lt;code&gt;AuthorizationException&lt;/code&gt; or &lt;code&gt;NotFoundException&lt;/code&gt; crosses out of the business logic module, something still has to decide it becomes a 401 or a 404. Chapter 11 is about where that decision lives, and why the domain layer should never be the one making it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Codebase &amp;amp; Architecture Blueprint
&lt;/h3&gt;

&lt;p&gt;The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 10, use the following link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Repository (Tag: &lt;code&gt;chapter-10-bl-testing&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/KamenIvanov/advanced-spring-multimodule/tree/chapter-10-bl-testing" rel="noopener noreferrer"&gt;advanced-spring-multimodule&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: All core modules are configured with strict compilation-level boundaries. Compile and run &lt;code&gt;mvn clean install&lt;/code&gt; to see the structure in action. Maven version 3.9.* and Java 25 are required.&lt;/p&gt;

&lt;p&gt;◀️ &lt;a href="https://dev.to/kamenivanov/testing-the-service-layer-part-1-what-the-generic-suite-owes-every-service-chapter-10-2ok4"&gt;Read Chapter 10, Part 1: What the Generic Suite Owes Every Service&lt;/a&gt;&lt;br&gt;
▶️ Read Chapter 11: Error Handling and Domain Exceptions Across Module Boundaries (Coming soon)&lt;/p&gt;




&lt;h3&gt;
  
  
  Join the Masterclass Journey
&lt;/h3&gt;

&lt;p&gt;This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, &lt;strong&gt;&lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;subscribe to my Substack Newsletter here&lt;/a&gt;&lt;/strong&gt;!&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;Disclosure: I earn a commission if you purchase through the links below, at no additional cost to you.&lt;/p&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code&lt;/em&gt; &lt;strong&gt;&lt;em&gt;friends20&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;for an exclusive&lt;/em&gt; &lt;strong&gt;&lt;em&gt;20% discount&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>java</category>
      <category>testing</category>
      <category>qa</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Testing the Service Layer - Part 1: What the Generic Suite Owes Every Service (Chapter 10)</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Tue, 15 Sep 2026 12:00:50 +0000</pubDate>
      <link>https://dev.to/kamenivanov/testing-the-service-layer-part-1-what-the-generic-suite-owes-every-service-chapter-10-2ok4</link>
      <guid>https://dev.to/kamenivanov/testing-the-service-layer-part-1-what-the-generic-suite-owes-every-service-chapter-10-2ok4</guid>
      <description>&lt;p&gt;In the preceding chapters, I built a clean architecture with a distinct demarcation line between the domain model, infrastructure, and web layers. The services orchestrate business logic without any awareness of Hibernate, SQL, or HTTP protocols. I apply Spring’s &lt;code&gt;@Transactional&lt;/code&gt; declaratively solely at the service level as the transaction boundary of the Use Case, while keeping the services themselves completely clean.&lt;/p&gt;

&lt;p&gt;Now I collect the biggest dividend of that discipline: &lt;strong&gt;the ability to test the entire business logic in milliseconds, with zero dependencies on the Spring context and zero real databases&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is a fixture I'd left half-finished.&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;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="o"&gt;())).&lt;/span&gt;&lt;span class="na"&gt;thenAnswer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;invocation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getArgument&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;//  domain.setId(entityId);&lt;/span&gt;
    &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCreatedById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;savedEntity&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;domain&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;domain&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That commented-out line had been sitting there since the first draft of this fixture, a placeholder from when I was sketching out what a persisted entity should look like, meant to be finished once I got back to it. I didn't get back to it. The test above it kept passing anyway, for reasons I hadn't stopped to check, and a commented-out line that nobody's chasing has a way of looking like it isn't hiding anything. It was, once I went to actually uncomment it. &lt;code&gt;Product&lt;/code&gt; extends a base class where &lt;code&gt;id&lt;/code&gt; is declared &lt;code&gt;final&lt;/code&gt;, assigned once in the constructor, never touched again for the life of the object. There is no setter to call - the line wasn't just unfinished, it was never going to compile. Which meant the fixture had never actually simulated what it claimed to: a DAO assigning an identity on persistence. It had been silently omitting that step since the day I wrote it, and the test suite built on top of it - every update, delete, and loadById test that depends on this same fixture for a "persisted" entity had never noticed, because none of those tests were checking whether the id it produced meant anything. They were checking whether &lt;em&gt;a&lt;/em&gt; value came back, and a value always did.&lt;/p&gt;

&lt;p&gt;Once I saw it, the question stopped being "why is this line wrong" and became "why does this class refuse to let it be right." A mutable id would mean an object could change what it is mid-lifecycle - that a &lt;code&gt;Product&lt;/code&gt; persisted under one identity could quietly become a different &lt;code&gt;Product&lt;/code&gt; without anyone reassigning the reference. The equality contract further up the hierarchy is built entirely on that id. If it moved, two variables holding what used to be the same entity could silently stop agreeing on whether they still were. Making &lt;code&gt;id&lt;/code&gt; final wasn't an oversight that happened to inconvenience a test author. It was a decision that a domain object's identity is not something even its own author gets to revise after the fact and a mock that pretends otherwise isn't a shortcut, it's a small act of denying that decision ever happened. Which means the actual bug wasn't the missing setter. The actual bug was a fixture that had been left to imply persistence without ever actually simulating it. A real DAO doesn't mutate the transient instance you hand it. It takes what you give it - no identity yet, nothing persisted and it hands back something new, same data, an id that didn't exist a moment ago. The mock's job was never to make the assertion pass. It was to tell the same story a real database would tell, minus the SQL.&lt;/p&gt;

&lt;p&gt;This is the shape most of the mistakes in this chapter turned out to have. Not a typo, not a missed edge case in the everyday sense, but a test quietly rewriting the contract of the thing it was supposed to be checking, because rewriting the contract was easier than satisfying it. &lt;code&gt;AbstractCrudServiceTestCase&lt;/code&gt; - the generic suite that every CRUD service in this codebase is meant to inherit for free, three abstract hooks and nothing else to implement, had this problem baked into its shared fixture, which meant the problem wasn't confined to one test. It was inherited by every service that extended the base class, silently, the way a real trait gets inherited: without anyone deciding to pass it on. That word - inherited, is worth sitting with the rest of this chapter, because it cuts both ways, and the good half of it is the actual point. The same mechanism that let one broken fixture assumption propagate to every concrete service is the mechanism that, once fixed, lets every concrete service pick up the correct behavior without anyone touching them again. &lt;code&gt;ProductsServiceTestCase&lt;/code&gt; and &lt;code&gt;CategoriesServiceTestCase&lt;/code&gt; didn't need to relearn what ownership-stamping means, or what an idempotent delete looks like, or how authorization should fail. They inherited it - the way an organism inherits a nervous system rather than growing one from scratch each generation. And they only had to express what was actually theirs: how a &lt;code&gt;Product&lt;/code&gt; maps its own fields, what it means for a &lt;code&gt;Product&lt;/code&gt; specifically to change status, what a &lt;code&gt;Category&lt;/code&gt; does differently. Software that evolves rather than gets rewritten looks like this from the inside. Not code that never changes, but code where the right thing changes and nothing else has to.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why mock the DAO instead of an in-memory database
&lt;/h2&gt;

&lt;p&gt;Before any of that inheritance can happen, there's a decision underneath it that's easy to walk past: why mock the DAO at all, instead of pointing every test at an in-memory database and calling it a day. I'd spent years testing the persistence layer that way - an H2 instance, Flyway migrations running against it, real SQL executing against a real schema and it worked, in the sense that it caught real bugs. But it was testing a different layer than the one I needed to test here, and conflating the two is exactly the kind of unexamined habit that produces slow, ambiguous test suites without anyone deciding that's what they wanted.&lt;/p&gt;

&lt;p&gt;An in-memory database test of &lt;code&gt;ProductsServiceImpl.create()&lt;/code&gt; verifies two things at once, bundled together whether you like it or not: that the service's own logic is correct, and that Hibernate's mapping, the schema, and the query it generates are all correct. When that test fails, you don't know which of those two failed without opening the stack trace and reading past the assertion into the actual exception. A &lt;code&gt;ConstraintViolationException&lt;/code&gt; on a NOT NULL column tells you the schema and the entity disagree about nullability - a real, valuable thing to know but it tells you nothing about whether &lt;code&gt;authorize()&lt;/code&gt; correctly rejects a non-owner, which is a business rule with no SQL in it at all. Persistence-layer testing, which I covered back in Chapter 7, exists precisely to isolate that first category of failure. Business-logic testing needs to isolate the second, which means the persistence layer has to be prevented from participating in the test at all. A &lt;code&gt;Dao&lt;/code&gt; mock doesn't approximate a database, it refuses to be one. &lt;code&gt;productDao.create(any())&lt;/code&gt; returns exactly what the test tells it to return, nothing more, and if &lt;code&gt;ProductsServiceImpl.create()&lt;/code&gt; is wrong, that wrongness has nowhere to hide behind an ORM quirk. The cost of that isolation is that a mocked-DAO test suite can be lying to you in a way an in-memory database test can't. Mockito doesn't know what &lt;code&gt;ProductDao.create()&lt;/code&gt; is supposed to do, it only knows what you told it to do when you wrote &lt;code&gt;when(...).thenAnswer(...)&lt;/code&gt;. If that stub embeds an assumption that doesn't match how the real &lt;code&gt;ProductDaoImpl&lt;/code&gt; actually behaves, the test can pass forever while the production code silently diverges from the fixture's model of it. That's not a hypothetical risk, it's the exact failure mode the half-finished &lt;code&gt;create()&lt;/code&gt; fixture represented, just at the interface boundary instead of inside a single test. Coverage percentage cannot see this category of bug, because coverage measures whether a line executed, not whether the value flowing through that line at test time was honest. A mocked-DAO suite earns its speed and its isolation by taking on a permanent, structural obligation: every stub has to be checked, by a human, against what the real implementation actually contracts to do - the DAO's Javadoc, its own persistence-layer tests from Chapter 7, its behavior under the constraints the database schema actually enforces. Chapter 7 and this chapter aren't separate concerns that happen to share a domain object, they're two halves of a coverage claim that's only true if both halves hold, and neither replaces the other's job.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the abstract suite actually asks of a new service
&lt;/h2&gt;

&lt;p&gt;The whole promise of the base class is that a new CRUD service - some future &lt;code&gt;OrdersServiceImpl&lt;/code&gt;, some future &lt;code&gt;WarehousesServiceImpl&lt;/code&gt; - gets four operations, fully tested, in exchange for implementing three methods: a create transformer, an update transformer, and an authorization rule. Everything else is inherited, unquestioned, from &lt;code&gt;AbstractCrudService&lt;/code&gt; and its matching &lt;code&gt;AbstractCrudServiceTestCase&lt;/code&gt;. It's worth walking through why each of those four operations is shaped the way it is, because none of the shapes were arbitrary, and a few of them only look obvious in hindsight. &lt;code&gt;create()&lt;/code&gt; has exactly one guard - a null &lt;code&gt;requesterId&lt;/code&gt; throws before anything else runs, and then it does something that's easy to read past: it stamps &lt;code&gt;createdById&lt;/code&gt; and &lt;code&gt;updatedById&lt;/code&gt; onto the domain object itself, before the DAO ever sees it. The test that proves this uses an &lt;code&gt;ArgumentCaptor&lt;/code&gt; rather than inspecting the method's return value, and the distinction matters more than it looks like it should.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;create_WhenRequesterIdIsPresent_ShouldStampOwnershipBeforePersisting&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NewDomain&lt;/span&gt; &lt;span class="n"&gt;newDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createValidNewDomain&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;assignedId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="o"&gt;())).&lt;/span&gt;&lt;span class="na"&gt;thenAnswer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;withId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getArgument&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;assignedId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getService&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newDomain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ArgumentCaptor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Domain&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;captor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ArgumentCaptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;captor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;times&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="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;captor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;capture&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt; &lt;span class="n"&gt;domainPassedToDao&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;captor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getValue&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// A transient domain has no identity until the DAO assigns one - the service must never invent an id itself.&lt;/span&gt;
    &lt;span class="n"&gt;assertNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domainPassedToDao&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domainPassedToDao&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedById&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domainPassedToDao&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUpdatedById&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignedId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedById&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 test needs to distinguish two moments in time that a single returned object collapses into one: what the service handed the DAO before persistence, and what the DAO handed back after. If I only inspected &lt;code&gt;result&lt;/code&gt; - the return value of &lt;code&gt;create()&lt;/code&gt;, I'd be looking at the &lt;em&gt;post&lt;/em&gt;-persistence object, the one &lt;code&gt;withId()&lt;/code&gt; constructed, and I'd have no way of proving that &lt;code&gt;createdById&lt;/code&gt; was already set on the object &lt;em&gt;before&lt;/em&gt; it reached the DAO rather than after. That distinction sounds pedantic until you imagine the bug it protects against: a future version of &lt;code&gt;create()&lt;/code&gt; that persists the domain object first and only stamps ownership afterward, in a follow-up &lt;code&gt;dao.update()&lt;/code&gt; call. That version would produce an identical-looking &lt;code&gt;result&lt;/code&gt;. It would also mean a brief window during which an unowned entity exists in the database, which is precisely the kind of window a security review should be able to trust the test suite to have already ruled out. &lt;code&gt;ArgumentCaptor.capture()&lt;/code&gt; freezes the argument at the moment of the call, not at the moment of the test's assertion, which is the only way to make that distinction visible.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;withId()&lt;/code&gt; itself exists because of the same immutability the opening story ran into. There is no &lt;code&gt;setId()&lt;/code&gt; anywhere in the hierarchy, by design, so a mock that simulates persistence has to construct a genuinely new instance carrying the assigned identity rather than mutate the one it was handed. Each concrete test class implements it once, against its own domain object's actual constructors:&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;protected&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="nf"&gt;withId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;transientProduct&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="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;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;transientProduct&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedById&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;transientProduct&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUpdatedById&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;transientProduct&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;transientProduct&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSku&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;transientProduct&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;transientProduct&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;update()&lt;/code&gt; carries a signature decision worth naming directly: &lt;code&gt;authorize(Domain domain, UUID requesterId)&lt;/code&gt; takes the full, already-loaded entity, never just its id. It would have been the more obvious signature to reach for, and it would have quietly forced every implementation to reload the entity a second time inside &lt;code&gt;authorize()&lt;/code&gt; itself, since ownership checks need to inspect &lt;code&gt;getCreatedById()&lt;/code&gt;, and an id alone tells you nothing about who created anything. Passing the already-loaded domain object means the one &lt;code&gt;dao.loadById()&lt;/code&gt; call inside &lt;code&gt;update()&lt;/code&gt;, &lt;code&gt;loadById()&lt;/code&gt;, and &lt;code&gt;delete()&lt;/code&gt; does double duty: it fetches the entity for the operation, and it hands that same instance to the authorization check without a second round trip. In a mocked-DAO test, this shows up as a single &lt;code&gt;verify(getMockDao(), times(1)).loadById(...)&lt;/code&gt; and the fact that it's &lt;code&gt;times(1)&lt;/code&gt; and not &lt;code&gt;times(2)&lt;/code&gt; is itself asserting something about the architecture, not just about the test. If a future refactor accidentally reintroduced a second load inside &lt;code&gt;authorize()&lt;/code&gt;, this test would catch it immediately, because the interaction count would change even though every return value would still look correct.&lt;/p&gt;

&lt;p&gt;The update test that checks ownership survives correctly is doing a similarly specific job, and it's worth naming exactly what regression it exists to catch, because on the surface it looks like it's testing something that can't fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;update_WhenRequesterIsOwner_ShouldPersistWithUpdatedByChangedAndCreatedByPreserved&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt; &lt;span class="n"&gt;existingDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;persistedEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;UpdateDomain&lt;/span&gt; &lt;span class="n"&gt;updateDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createUpdateDomain&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;loadById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existingDomain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())).&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existingDomain&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="o"&gt;())).&lt;/span&gt;&lt;span class="na"&gt;thenAnswer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;invocation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getArgument&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="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getService&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existingDomain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;updateDomain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ArgumentCaptor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Domain&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;captor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ArgumentCaptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;captor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;times&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="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;captor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;capture&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt; &lt;span class="n"&gt;persisted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;captor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getValue&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existingDomain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;persisted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;persisted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedById&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;persisted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUpdatedById&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existingDomain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getUpdateTransformer().copyToOutput(updateDomain, domain)&lt;/code&gt; is supposed to copy only the fields the update DTO actually carries - name, SKU, price, dimensions onto the loaded domain object. Nothing about &lt;code&gt;UpdateProduct&lt;/code&gt; or &lt;code&gt;UpdateCategory&lt;/code&gt; mentions &lt;code&gt;createdById&lt;/code&gt;. But "the DTO doesn't carry the field" and "the transformer definitely won't touch the field" are two different guarantees, and only one of them is enforced by the type system. The shared transformer base class both &lt;code&gt;UpdateProductTransformer&lt;/code&gt; and &lt;code&gt;UpdateCategoryTransformer&lt;/code&gt; extend is exactly the kind of shared code where a well-intentioned future change could add a line that resets audit fields as a default, on the reasoning that every update should reset something. If that line ever landed, every existing test that only asserts on name, SKU, and price would keep passing, because none of them look at &lt;code&gt;createdById&lt;/code&gt; at all. The test doesn't exist because I distrust today's transformer. It exists because the transformer is shared ancestry, and shared ancestry is exactly where a mistake gets inherited by every subclass at once - the same lesson the opening story already taught the hard way.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;loadById()&lt;/code&gt; and &lt;code&gt;delete()&lt;/code&gt; follow the same authorization pattern as &lt;code&gt;update()&lt;/code&gt;, with one deliberate asymmetry worth calling out: &lt;code&gt;delete()&lt;/code&gt; is idempotent by design, established back in Chapter 9 on RFC 7231 grounds, and the test proving it looks almost suspiciously 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="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;delete_WhenEntityDoesNotExist_ShouldBeANoOp&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;randomId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;loadById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;randomId&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;assertDoesNotThrow&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;getService&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;randomId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;times&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="na"&gt;loadById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;randomId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getMockDao&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;never&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&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;There's no exception to catch, no return value to inspect, the entire assertion is behavioral: the DAO gets asked to look, and is never asked to delete something that was never there. It's a small test, and it's easy to underrate exactly because it's small. A previous version of this same test verified &lt;code&gt;dao.create()&lt;/code&gt; was never called instead of &lt;code&gt;dao.update()&lt;/code&gt; - the wrong method entirely, copy-pasted down from a sibling test and never corrected. It would have passed forever regardless of what &lt;code&gt;delete()&lt;/code&gt; actually did, because nothing it checked had anything to do with deletion. The fix wasn't clever, it was reading the test's own name against its own assertions and noticing they'd stopped agreeing with each other.&lt;/p&gt;




&lt;h2&gt;
  
  
  BigDecimal, and the failure that only shows up later
&lt;/h2&gt;

&lt;p&gt;One more small decision belongs in this section, not because it's exotic, but because it's the kind of mistake that's genuinely invisible until it produces a wrong invoice in production. &lt;code&gt;BigDecimal.equals()&lt;/code&gt; considers scale part of the value being compared - &lt;code&gt;new BigDecimal("239.99").equals(new BigDecimal("239.990"))&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;, despite both representing the identical monetary amount. A field-mapping test asserting &lt;code&gt;assertEquals(newDto.getPrice(), result.getPrice())&lt;/code&gt; would be correct today, while the DTO and the domain object happen to construct their &lt;code&gt;BigDecimal&lt;/code&gt; values with matching scale, and would become a source of mysterious, intermittent test failures the moment either side's construction path changed - a different &lt;code&gt;BigDecimal.valueOf(...)&lt;/code&gt; overload, a database column that returns a different scale than the one supplied. Every price assertion in this suite uses &lt;code&gt;compareTo() == 0&lt;/code&gt; instead, because that's the only question that actually matters for money: are these the same amount, not are these represented identically.&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;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newDto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;compareTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting this wrong in a chapter about testing discipline would have been its own small failure of the thing the chapter argues for.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's inherited, and what isn't yet
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AbstractCrudServiceTestCase&lt;/code&gt; now does exactly what it was supposed to: every guard, every ownership rule, every authorization path for &lt;code&gt;create()&lt;/code&gt;, &lt;code&gt;update()&lt;/code&gt;, &lt;code&gt;loadById()&lt;/code&gt;, and &lt;code&gt;delete()&lt;/code&gt; is written once, and any CRUD service that extends &lt;code&gt;AbstractCrudService&lt;/code&gt; picks all of it up without writing a line. &lt;code&gt;ProductsServiceTestCase&lt;/code&gt; and &lt;code&gt;CategoriesServiceTestCase&lt;/code&gt; prove it by how little they have to add on top - field mapping through their own transformer, and nothing else that this generic contract already covers.&lt;/p&gt;

&lt;p&gt;But "nothing else that this generic contract covers" is doing some quiet work in that sentence, because there's a whole method -  &lt;code&gt;changeStatus()&lt;/code&gt; sitting on both concrete services that the abstract suite never touches at all, and it's worth asking why before assuming it's an oversight.&lt;/p&gt;

&lt;p&gt;Part 2 picks up exactly there: why &lt;code&gt;changeStatus()&lt;/code&gt; was deliberately left out of the shared ancestor, a coverage gap that survived hiding inside a fully-executed branch, and one more pass through the test suite itself to find a redundancy that had no business surviving this long.&lt;/p&gt;




&lt;h3&gt;
  
  
  Codebase &amp;amp; Architecture Blueprint
&lt;/h3&gt;

&lt;p&gt;The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 10, use the following link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Repository (Tag: &lt;code&gt;chapter-10-bl-testing&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/KamenIvanov/advanced-spring-multimodule/tree/chapter-10-bl-testing" rel="noopener noreferrer"&gt;advanced-spring-multimodule&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: All core modules are configured with strict compilation-level boundaries. Compile and run &lt;code&gt;mvn clean install&lt;/code&gt; to see the structure in action. Maven version 3.9.* and Java 25 are required.&lt;/p&gt;

&lt;p&gt;◀️ &lt;a href="https://dev.to/kamenivanov/the-core-business-logic-module-chapter-9-iek"&gt;Read Chapter 9: The Core Business Logic Module&lt;/a&gt;&lt;br&gt;
▶️ Read Chapter 10 - Part 2: Testing the Service Layer: Where the Shared Ancestor Ends (Coming soon)&lt;/p&gt;




&lt;h3&gt;
  
  
  Join the Masterclass Journey
&lt;/h3&gt;

&lt;p&gt;This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, &lt;strong&gt;&lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;subscribe to my Substack Newsletter here&lt;/a&gt;&lt;/strong&gt;!&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code &lt;strong&gt;friends20&lt;/strong&gt; for an exclusive &lt;strong&gt;20% discount&lt;/strong&gt; automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>java</category>
      <category>programming</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Our H2 tests passed but postgres in production disagreed</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Mon, 14 Sep 2026 07:20:09 +0000</pubDate>
      <link>https://dev.to/kamenivanov/our-h2-tests-passed-but-postgres-in-production-disagreed-4lca</link>
      <guid>https://dev.to/kamenivanov/our-h2-tests-passed-but-postgres-in-production-disagreed-4lca</guid>
      <description>&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%2Fnjvucu3n6x75h8ohegmt.jpeg" 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%2Fnjvucu3n6x75h8ohegmt.jpeg" alt="Cover" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The test suite was green, as it had been for months. The migration ran clean on H2, the repository tests passed, the CI pipeline gave its usual thumbs up, and the change went out. A few hours later, a batch job that touched category records started failing in production with an error none of us recognized on sight: invalid input value for enum category_status. We didn't have that enum in our test environment. We had a &lt;code&gt;VARCHAR&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;The status field had gone through a refactor earlier in the year, moving from a loose two-value flag to a proper three-value enum, &lt;code&gt;ACTIVE&lt;/code&gt;, &lt;code&gt;INACTIVE&lt;/code&gt;, &lt;code&gt;ARCHIVED&lt;/code&gt;. On Postgres, that was implemented as a native enum type at the database level, which is the stricter, more correct way to do it, since the database itself now refuses anything outside the three allowed values. On H2, for reasons that made sense at the time and stopped making sense the day this happened, the equivalent migration script defined the column as a plain string. H2 doesn't support native Postgres enum types, so somewhere along the way the two migration scripts had quietly diverged, one for tests, one for production, and nobody had flagged it as a problem because both passed their respective test suites.&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%2F0v3xdcv496auqr2cgb5o.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%2F0v3xdcv496auqr2cgb5o.png" alt="H2 vs Postgress difference" width="680" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The batch job was writing a leftover status value from before the refactor, a string that used to be valid and no longer was. On H2, that string went into a &lt;code&gt;VARCHAR&lt;/code&gt; column without complaint. On Postgres, the database did exactly what a native enum type is supposed to do and rejected it outright. The bug wasn't really in the batch job. It was in the fact that our tests had never once run against a schema that could actually catch this class of error, because the schema they ran against wasn't the same schema running in production.&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="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mo"&gt;03&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;07.113&lt;/span&gt; &lt;span class="no"&gt;ERROR&lt;/span&gt; &lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;spi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SqlExceptionHelper&lt;/span&gt;
&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;util&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PSQLException&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;ERROR:&lt;/span&gt; &lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nl"&gt;category_status:&lt;/span&gt; &lt;span class="s"&gt;"LEGACY_HIDDEN"&lt;/span&gt;
&lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="nc"&gt;CategoryStatusUpdateBatchJob&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;processRecord&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CategoryStatusUpdateBatchJob&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;java&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hibernate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SQLStateConversionDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;convert&lt;/span&gt;
&lt;span class="no"&gt;H2&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="nl"&gt;job:&lt;/span&gt;&lt;span class="err"&gt;&amp;nbsp;&lt;/span&gt;&lt;span class="no"&gt;PASSED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We moved that test suite onto Testcontainers, running the real Postgres migration script instead of an H2 substitute, and reran the batch job's test coverage against it. It failed on the first run. Ten minutes of investigation, and it was pointing at the exact same invalid enum value production had hit, except this time in a local test run instead of an incident channel.&lt;/p&gt;

&lt;p&gt;The lesson isn't that H2 is bad, it's fast and convenient for a lot of testing. The lesson is that convenience has a boundary, and that boundary is anywhere your production database enforces something your test database doesn't know how to enforce. If your schema does real work at the database level, constraints, enums, exclusion rules, your tests need to run against a database willing to do that same work. Otherwise green tests are only telling you the code runs, not that it's correct.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More like this?&lt;/strong&gt;&lt;br&gt;
I write short production war stories like this one, plus a deep-dive architecture series. &lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;Subscribe here&lt;/a&gt; if you want both in your inbox.&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code &lt;strong&gt;friends20&lt;/strong&gt; for an exclusive &lt;strong&gt;20% discount&lt;/strong&gt; automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>One Missing Parameter Doubled Our Connection Pool Wait Time</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Fri, 11 Sep 2026 08:47:15 +0000</pubDate>
      <link>https://dev.to/kamenivanov/one-missing-parameter-doubled-our-connection-pool-wait-time-30ag</link>
      <guid>https://dev.to/kamenivanov/one-missing-parameter-doubled-our-connection-pool-wait-time-30ag</guid>
      <description>&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%2Fnfsfnq01pb2xarj42n9e.jpeg" 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%2Fnfsfnq01pb2xarj42n9e.jpeg" alt="Cover" width="800" height="447"&gt;&lt;/a&gt;&lt;br&gt;
It was a Friday afternoon, the worst possible time for an incident, which is of course when it happened. Traffic was at its weekly peak, and our connection pool metrics started climbing in a way that didn't match the load. Requests weren't failing outright, they were just queuing, waiting longer and longer for a connection to free up. Nothing in the code had changed that week. Nothing in the infrastructure had changed either. The only thing that had changed was traffic volume, and the pool was buckling under it far earlier than our capacity planning said it should. The investigation started where these things usually start, with dashboards and guesses. CPU was fine. Database load was fine. The pool itself was correctly sized for what we thought our read-to-write ratio was. That assumption turned out to be the problem.&lt;/p&gt;

&lt;p&gt;A handful of endpoints, all of them read-only by contract, category listings, product lookups, dashboard summaries, were annotated with plain &lt;code&gt;@Transactional&lt;/code&gt;, missing the &lt;code&gt;readOnly = true&lt;/code&gt; attribute. They worked and they returned the correct data. Nothing about their behavior looked wrong in any functional test. But without that attribute, Hibernate treated every one of those calls as a potential write, It tracked every loaded entity for dirty checking, it ran a flush at the end of the transaction to look for changes to persist, and none of that work is free. On a single request it costs a few milliseconds nobody notices but multiply it across the volume of read traffic we get at peak, and connections were being held measurably longer than they needed to be, which meant fewer of them were cycling back into the pool for the next request in line.&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%2Fs7rpjzf0mqw2oj9e5iuj.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%2Fs7rpjzf0mqw2oj9e5iuj.png" alt="Before and After the fix comparison" width="556" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix was easy enough - adding &lt;code&gt;readOnly = true&lt;/code&gt; to those methods took an afternoon. Hibernate skips the dirty check, skips the flush, and the transaction closes as soon as the data is read. Pool wait times at the next peak dropped by roughly half, which lines up with how much of our traffic was hitting those endpoints in the first place.&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%2Fz4adna9kk44y4exhtrmx.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%2Fz4adna9kk44y4exhtrmx.png" alt="Connection Pool flow" width="680" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What stuck with me afterward wasn't the fix, it was how invisible the cost was until the volume made it visible. &lt;code&gt;readOnly = true&lt;/code&gt; reads like a hint, almost decorative, the kind of thing that's easy to skip when you're moving fast and the tests are green but it isn't decorative. It's telling the persistence layer to stop doing work it was never going to need, and at scale that work adds up to real contention on a resource every request in your system is competing for.&lt;/p&gt;

&lt;p&gt;If you're auditing your own services for this, the search is quick: grep for &lt;code&gt;@Transactional&lt;/code&gt; without &lt;code&gt;readOnly&lt;/code&gt;, then check whether the method actually writes anything. The attribute costs a few keystrokes but the absence of it costs connections you'll be short of exactly when you can least afford it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More like this?&lt;/strong&gt;&lt;br&gt;
I write short production war stories like this one, plus a deep-dive architecture series. &lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;Subscribe here&lt;/a&gt; if you want both in your inbox.&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code &lt;strong&gt;friends20&lt;/strong&gt; for an exclusive &lt;strong&gt;20% discount&lt;/strong&gt; automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>jpa</category>
      <category>hibernate</category>
    </item>
    <item>
      <title>The Core Business Logic Module (Chapter 9)</title>
      <dc:creator>Kamen</dc:creator>
      <pubDate>Mon, 07 Sep 2026 09:43:43 +0000</pubDate>
      <link>https://dev.to/kamenivanov/the-core-business-logic-module-chapter-9-iek</link>
      <guid>https://dev.to/kamenivanov/the-core-business-logic-module-chapter-9-iek</guid>
      <description>&lt;p&gt;A business layer is only as independent as the shape of the objects that cross its boundary. It doesn't matter how carefully a service class avoids importing &lt;code&gt;javax.servlet&lt;/code&gt; or a Kafka client if the object it accepts as input is a one-to-one mirror of the database row. At that point the transport has already leaked in, just wearing a plain Java object instead of a &lt;code&gt;@RequestBody&lt;/code&gt; annotation. The rule this chapter works from is narrow enough to apply consistently: a service method's input should reflect what a caller is allowed to &lt;em&gt;do&lt;/em&gt;, not what the entity happens to &lt;em&gt;look like&lt;/em&gt;. Creating a product and updating one are different operations with different rules about what's mutable, who sets what, and what's off-limits entirely - so they get different input types, even when large parts of them overlap. The same goes for who's asking: a &lt;code&gt;requesterId&lt;/code&gt; arrives as a bare &lt;code&gt;UUID&lt;/code&gt;, and the service has no idea about whether it came from a JWT claim, a session, or a Kafka message header. That indifference is the actual independence being aimed for, not the absence of framework imports.&lt;/p&gt;

&lt;p&gt;What makes this rule worth a full chapter, rather than a paragraph, is the case where following it isn't obvious, where a field looks like it belongs on one side of the boundary until you actually reason through what the operation is supposed to guarantee.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "independent" is supposed to mean
&lt;/h2&gt;

&lt;p&gt;"Business logic should be independent of transport and infrastructure" gets repeated often enough that it stops meaning anything. In practice, for this service layer, it means three concrete things:&lt;/p&gt;

&lt;p&gt;The service methods don't know whether the caller is a REST controller, a Kafka listener, or a CLI tool. They take a &lt;code&gt;requesterId&lt;/code&gt; as a &lt;code&gt;UUID&lt;/code&gt; and a domain-shaped input, and that's the entire contract. Nothing about how that UUID was extracted - JWT claim, session or a message header.&lt;/p&gt;

&lt;p&gt;The service methods don't know what database sits behind the DAO. &lt;code&gt;AbstractCrudService&lt;/code&gt; depends on &lt;code&gt;CrudDao&amp;lt;UUID, Domain&amp;gt;&lt;/code&gt;, an interface. Whether that's MySQL, an in-memory test double, Apache Coherence or something else entirely is invisible from here.&lt;/p&gt;

&lt;p&gt;And the service methods don't accept whatever shape of data happens to be convenient for the caller. They accept exactly the shape that matches what the operation is allowed to do. That third point is where status transitions turn out to be a harder case than they first look, so it's worth slowing down on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Different intent, different object
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;NewCategory&lt;/code&gt; carries just a name - a category is created without a provided status at all, it's &lt;code&gt;INACTIVE&lt;/code&gt; by default until someone deliberately changes that. &lt;code&gt;UpdateCategory&lt;/code&gt; carries a name too, because renaming a category turned out to be a legitimate, unremarkable operation with no special rule attached to it. What it doesn't carry is status, and that absence is the interesting part:&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;UpdateCategory&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="c1"&gt;// no status field&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not the same reasoning as leaving a field off because it's immutable, name isn't immutable here, it's just not entangled with anything else. Status is left off for the opposite reason: changing it &lt;em&gt;is&lt;/em&gt; entangled with a rule, the same kind &lt;code&gt;Product&lt;/code&gt; enforces through &lt;code&gt;transitionTo&lt;/code&gt;. A category can move from &lt;code&gt;INACTIVE&lt;/code&gt; to &lt;code&gt;ACTIVE&lt;/code&gt; or straight to &lt;code&gt;ARCHIVED&lt;/code&gt;, but once &lt;code&gt;ACTIVE&lt;/code&gt; it can only be archived, and &lt;code&gt;ARCHIVED&lt;/code&gt; is terminal - no path back. None of that belongs in a generic update method next to a name change, so it doesn't live there. It lives in its own operation:&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;changeStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CategoryStatus&lt;/span&gt; &lt;span class="n"&gt;newStatus&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loadOrThrowNotFound&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;getDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;loadById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;authorize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newStatus&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setUpdatedById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;getDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&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 category originally shipped with a plain &lt;code&gt;active&lt;/code&gt; boolean, and for a while that was a perfectly reasonable representation, there were exactly two states, and a flag models two states fine. The same reasoning that shaped &lt;code&gt;ProductStatus&lt;/code&gt; into a guarded enum applied here too, once a third state showed up: a boolean has no way to express "archived" without either overloading &lt;code&gt;active = false&lt;/code&gt; to mean two different things, or bolting on a second flag and hoping the combination of both flags never goes somewhere nonsensical. The category status shows that lesson getting applied a second time, ahead of the problem this time rather than after it, a &lt;code&gt;CategoryStatus&lt;/code&gt; enum with the same &lt;code&gt;canTransitionTo&lt;/code&gt; shape as &lt;code&gt;ProductStatus&lt;/code&gt;, so a name change and a status change stay two operations that can never be confused for one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NewProduct&lt;/code&gt; and &lt;code&gt;UpdateProduct&lt;/code&gt; follow the same instinct for most fields, no caller ever gets to set &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;createdAt&lt;/code&gt;, or &lt;code&gt;createdById&lt;/code&gt; from outside, because those fields don't exist on either input object. They're populated by the service (&lt;code&gt;preProcessNewEntity&lt;/code&gt;) or never populated by input at all. A create payload and the domain object it produces are not the same shape, on purpose, and that gap is the whole point: it's not possible to forge a creation timestamp or claim someone else's authorship, because there's no field to put it in. The same discipline shows up in how &lt;code&gt;UpdateProductTransformer&lt;/code&gt; handles the embedded specification, and it's a smaller but no less deliberate decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSpecification&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;ProductSpecification&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDimensions&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getWeight&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setDimensions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDimensions&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSpecification&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getWeight&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;An update caller sends flat &lt;code&gt;dimensions&lt;/code&gt; and &lt;code&gt;weight&lt;/code&gt; fields, not a &lt;code&gt;ProductSpecification&lt;/code&gt; object. The transformer decides whether that means constructing a new embedded object or mutating an existing one, and the caller never has to know or care which branch ran. That's the input shape matching the &lt;em&gt;operation&lt;/em&gt;, not the internal object graph, a product created without dimensions can still be updated to add them later, without the caller needing to understand that &lt;code&gt;ProductSpecification&lt;/code&gt; exists as a separate type at all.&lt;/p&gt;

&lt;p&gt;Status is where that question gets harder to answer by instinct, and it's worth reasoning through it explicitly. At first glance, &lt;code&gt;status&lt;/code&gt; looks like it belongs on &lt;code&gt;UpdateProduct&lt;/code&gt; next to name, SKU, and price, it's a field on the entity, the caller wants to change it, so it goes in the update payload with the rest. That reasoning holds right up until you ask what "changing status" is actually supposed to mean, and the domain object itself answers that question before the DTO gets a chance to:&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;transitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProductStatus&lt;/span&gt; &lt;span class="n"&gt;nextStatus&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="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;canTransitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nextStatus&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;"Business rule violated: Cannot transition from "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" to "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;nextStatus&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nextStatus&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Product&lt;/code&gt; doesn't expose a &lt;code&gt;setStatus&lt;/code&gt;. It exposes a guarded transition. There's no legal way to skip from &lt;code&gt;DRAFT&lt;/code&gt; straight to &lt;code&gt;ARCHIVED&lt;/code&gt;, or to move backward from &lt;code&gt;PUBLISHED&lt;/code&gt; to &lt;code&gt;DRAFT&lt;/code&gt;, without the domain itself refusing. That's the correct place for that rule to live - not in a controller, not in a transformer, not scattered across whichever service happens to touch the domain next. Which means a plain field copy in the transformer - &lt;code&gt;product.setStatus(dto.getStatus())&lt;/code&gt; was never going to be the right move, regardless of whether anyone remembered to write it. It would have bypassed &lt;code&gt;canTransitionTo&lt;/code&gt; entirely, applying whatever status the caller sent without checking if the move was legal. Once that's clear, the field doesn't belong on &lt;code&gt;UpdateProduct&lt;/code&gt; at all, not because leaving it there caused a defect, but because a generic update method has no business deciding whether a status transition is allowed. That's a different kind of operation, with its own rule, and it earns its own method - the same one &lt;code&gt;Category&lt;/code&gt; already uses:&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;changeStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ProductStatus&lt;/span&gt; &lt;span class="n"&gt;newStatus&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loadOrThrowNotFound&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;getDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;loadById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;authorize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newStatus&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setUpdatedById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;getDao&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&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's worth noticing that this is the exact same shape as &lt;code&gt;Category.changeStatus&lt;/code&gt; - same load, same authorize call, same transition, same save. Neither entity needed a bespoke &lt;code&gt;publish()&lt;/code&gt; or &lt;code&gt;activate()&lt;/code&gt; method with its own hand-rolled orchestration. The variation between products and categories lives entirely inside &lt;code&gt;canTransitionTo&lt;/code&gt;, where it belongs, not in how the operation is wired up around it.&lt;/p&gt;

&lt;p&gt;Now there are three independent layers standing between a caller and an invalid state: the DTO doesn't carry the field at all, the service exposes the change as a named, intentional action, and the domain object enforces the transition rule regardless of who calls it. Any one of those layers failing doesn't compromise the other two. That's worth more than a single well-placed validation, because it's the kind of protection that survives someone else touching the code six months from now without having read this chapter.&lt;/p&gt;

&lt;p&gt;The same status reasoning extends one layer further, past persistence. &lt;code&gt;CategoryStatus&lt;/code&gt; - the domain enum with &lt;code&gt;canTransitionTo&lt;/code&gt; and &lt;code&gt;CategoryStatusEntity&lt;/code&gt; the JPA-mapped enum stored as a string column are two separate types, converted explicitly at the DAO boundary rather than shared directly. The domain module never imports anything from &lt;code&gt;jakarta.persistence&lt;/code&gt;, so it has no idea &lt;code&gt;EnumType.STRING&lt;/code&gt; exists, or that the column is even a string rather than an integer code. That's the same independence argument as the transport boundary, aimed at the other side of the service: the business rule for what states a category can move through doesn't change if the storage representation does.&lt;/p&gt;




&lt;h2&gt;
  
  
  The template, and what it deliberately doesn't decide
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AbstractCrudService&lt;/code&gt; is the same shape for both &lt;code&gt;ProductsServiceImpl&lt;/code&gt; and &lt;code&gt;CategoriesServiceImpl&lt;/code&gt;, and that repetition is intentional rather than lazy. &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;loadById&lt;/code&gt;, and &lt;code&gt;delete&lt;/code&gt; are implemented once, in the base class, calling out to a small set of abstract hooks:&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;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nc"&gt;Transformer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CreateDomain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getCreateTransformer&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nc"&gt;Transformer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UpdateDomain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getUpdateTransformer&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Domain&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;requesterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each concrete service answers three questions - how do I build this domain object from a create payload, how do I apply an update payload, and who's allowed to touch this domain object and the orchestration around those answers (transaction boundaries, null checks, the not-found and unauthorized exceptions) is written exactly once.&lt;/p&gt;

&lt;p&gt;It's worth addressing the one place this chapter's independence argument looks like it bends: every write method carries &lt;code&gt;@Transactional&lt;/code&gt;, a Spring annotation, sitting directly in what's supposed to be framework-agnostic business logic. That's a smaller compromise than it looks. &lt;code&gt;@Transactional&lt;/code&gt; is declarative metadata, read reflectively by a proxy Spring builds around the class - the method itself never touches a &lt;code&gt;TransactionManager&lt;/code&gt;, never begins or commits anything, never knows the annotation is even being honored. Strip Spring from the classpath and the method still compiles and runs, just without the transactional wrapping. That's a fundamentally different kind of coupling than a JPA entity or an &lt;code&gt;HttpServletRequest&lt;/code&gt; parameter would be, where the method literally cannot execute without the framework type on the classpath.&lt;br&gt;
The alternative - moving transaction demarcation out to the controller, or configuring it externally via XML or AspectJ doesn't remove the framework, it just relocates a decision that belongs here anyway: what counts as one atomic unit of work for a given operation is a business question, not a transport one. A controller has no basis for deciding whether &lt;code&gt;update()&lt;/code&gt; should be one transaction or two. That's exactly why this rule is enforced at exactly one layer. &lt;code&gt;@Transactional&lt;/code&gt; lives on service methods, in this base class or its concrete implementations, and nowhere else. Not on controllers, not inside DAO implementations. A transaction boundary that can start from either direction is a transaction boundary nobody can reason about.&lt;/p&gt;

&lt;p&gt;Notice also what &lt;code&gt;Transformer&amp;lt;CreateDomain, Domain&amp;gt;&lt;/code&gt; doesn't do: it only moves data one direction, from the create or update input object into the domain object it produces. There's no &lt;code&gt;copyToInput&lt;/code&gt; here for turning that domain object back into a response shape, that's a different interface entirely, used elsewhere in the codebase for DTO serialization. Collapsing the two into one bidirectional transformer would be a natural shortcut, since a lot of the field-copying code would look identical going either direction. It would also mean the same class that decides how a creation payload becomes a product is now also deciding how a product becomes an API response and the moment those two responsibilities share a class, it becomes easy to add a field to one direction and forget the other silently breaks the boundary this chapter is about. Keeping create/update transformers strictly one-way means that possibility doesn't exist structurally, not just by convention.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;authorize&lt;/code&gt; is worth pausing on, because it takes the fully loaded domain object, not just an id:&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;protected&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;requesterId&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;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedById&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;requesterId&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;AuthorizationException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UNAUTHORIZED&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;That forces a load before the authorization check can run, which looks like it could be optimized away. It can't, not without losing generality. Ownership by creator works for products, but nothing guarantees the next domain object's access rule is that simple, it could depend on a collaborator list, a tenant boundary, a status field, any combination of the object's own state. The moment authorization can depend on arbitrary domain content, loading the object first stops being a choice and becomes a requirement. The base class doesn't try to special-case this, it just accepts the domain object as a parameter and lets each subclass decide what "authorized" means for that domain.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;delete&lt;/code&gt; carries a smaller, quieter version of the same idea:&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dao&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loadById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="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;Deleting something that doesn't exist isn't an error here - it's a no-op. That's a defensible, idempotent design, and it matches how HTTP itself defines the semantics of DELETE: RFC 7231 specifies DELETE as idempotent, meaning repeated identical requests should leave the system in the same end state as a single one - a resource that's already gone stays gone, without that repetition needing to be an error. It's also easy to violate by accident. When someone adds anything after &lt;code&gt;dao.delete()&lt;/code&gt; in this method - an audit entry, a notification, anything - that addition needs to remember this early return exists, or it'll fire for deletes that never happened. Worth a comment at the call site, not because the current code is wrong, but because the next change to it won't have this context unless it's written down.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this buys, concretely
&lt;/h2&gt;

&lt;p&gt;None of this is abstract payoff. A &lt;code&gt;ProductsServiceImpl&lt;/code&gt; constructed with nothing but a &lt;code&gt;ProductDao&lt;/code&gt; can be exercised with a mocked DAO and zero Spring context - no database, no HTTP layer, no message broker, because none of those things are reachable from inside the class in the first place. The transformer contract only moves data one direction, so there's no path by which a create or update payload construction accidentally becomes the shape used for API responses later. And status transitions, once reasoned through as their own kind of operation rather than just another field, end up structurally incapable of bypassing the domain object's own rules, not because someone remembered to guard against it, but because the shortcut was never given a path to exist.&lt;/p&gt;

&lt;p&gt;The same boundary is what makes future changes cheap instead of invasive. If &lt;code&gt;ProductDao&lt;/code&gt; moves from MySQL to something else, or gets a caching layer bolted in front of it, &lt;code&gt;ProductsServiceImpl&lt;/code&gt; doesn't change, it was never coupled to the implementation, only the interface. The same will hold when this service eventually needs to notify the rest of the system that a product was created or updated, that's a dependency the service will accept through its constructor, the same way it accepts the DAO now, and the create and update methods above won't need to know whether that notification goes out over Kafka directly or through an outbox table written in the same transaction. That's a later chapter's problem specifically because this chapter's boundary makes it one.&lt;/p&gt;

&lt;p&gt;The tests that prove all of this - what a mocked DAO actually looks like, how you assert an unauthorized update throws before it touches persistence, how you write a test that locks in a status transition rule so a future change can't quietly loosen it are their own chapter. This one was about the shape the code has to have before those tests are worth writing.&lt;/p&gt;




&lt;h3&gt;
  
  
  What's Next?
&lt;/h3&gt;

&lt;p&gt;Chapter 10 walks through testing this exact service layer with the DAO mocked out entirely, no Spring context, no database, no HTTP layer, using &lt;code&gt;ProductsServiceImpl&lt;/code&gt; and &lt;code&gt;CategoriesServiceImpl&lt;/code&gt; as the running examples. The boundary this chapter drew is what makes those tests possible in the first place - the next one is about actually writing them, including the test that would have caught the status field slipping through if it had shipped that way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Codebase &amp;amp; Architecture Blueprint
&lt;/h3&gt;

&lt;p&gt;The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 9, use the following link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Repository (Tag: &lt;code&gt;chapter-09-business-logic&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/KamenIvanov/advanced-spring-multimodule/tree/chapter-09-business-logic" rel="noopener noreferrer"&gt;advanced-spring-multimodule&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: All core modules are configured with strict compilation-level boundaries. Compile and run &lt;code&gt;mvn clean install&lt;/code&gt; to see the structure in action. Maven version 3.9.* and Java 25 are required.&lt;/p&gt;

&lt;p&gt;◀️ &lt;a href="https://dev.to/kamenivanov/why-almost-every-temporary-workaround-is-permanent-chapter-8-2on5"&gt;Read Chapter 8: Why almost every ‘Temporary’ Workaround is Permanent&lt;/a&gt;&lt;br&gt;
▶️ &lt;a href="https://dev.to/kamenivanov/testing-the-service-layer-part-1-what-the-generic-suite-owes-every-service-chapter-10-2ok4"&gt;Read Chapter 10: The Testing of Business Logic Service&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Join the Masterclass Journey
&lt;/h3&gt;

&lt;p&gt;This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, &lt;strong&gt;&lt;a href="https://kamenivanov.substack.com/subscribe" rel="noopener noreferrer"&gt;subscribe to my Substack Newsletter here&lt;/a&gt;&lt;/strong&gt;!&lt;/p&gt;




&lt;h3&gt;
  
  
  Recommended Resources for Java &amp;amp; Spring Boot Engineers
&lt;/h3&gt;

&lt;p&gt;If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot &amp;amp; Architecture skills, check out these highly-rated resources from the &lt;strong&gt;Javarevisited&lt;/strong&gt; publication &lt;em&gt;(Use promo code &lt;strong&gt;friends20&lt;/strong&gt; for an exclusive &lt;strong&gt;20% discount&lt;/strong&gt; automatically applied at checkout)&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Java Interview&lt;/strong&gt;
Prepare for core Java, concurrency, JVM internals, and design pattern questions.
&lt;a href="https://gumroad.com/a/501547619/QqjGH" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/HMOAv" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the Spring Boot Interview&lt;/strong&gt;
Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices.
&lt;a href="https://gumroad.com/a/501547619/hrUXKY" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/pfolo" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grokking the SQL Interview&lt;/strong&gt;
Deep-dive into query optimization, indexing, joins, and complex SQL window functions.
&lt;a href="https://gumroad.com/a/501547619/fhmehw" rel="noopener noreferrer"&gt;Get Full Book (Paid)&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/brruh" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Complete Java + Spring + SQL Interview Bundle&lt;/strong&gt;
Get all three interview guides in a single heavily discounted package.
&lt;a href="https://gumroad.com/a/501547619/gvqgjk" rel="noopener noreferrer"&gt;Get the Ultimate Interview Bundle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Professional Certification Practice Questions (250+ Questions)&lt;/strong&gt;
Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification.
&lt;a href="https://gumroad.com/a/501547619/sygyq" rel="noopener noreferrer"&gt;Get the Spring Professional Questions&lt;/a&gt; | &lt;a href="https://gumroad.com/a/501547619/qelhye" rel="noopener noreferrer"&gt;Download Free Sample Copy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>java</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
